Back

/ 6 min read

Deploy Astro Page to S3 and CloudFront

Introduction

When deciding how to host my blog, I opted for AWS instead of Netlify. Why? I’m familiar with AWS, and it provides a cost-effective solution for hosting. Sure, a standard web hosting service could work, but where’s the fun in that?

In this post, I’ll share my approach to deploying a blog built with Astro to AWS, leveraging S3 and CloudFront. This setup works well for me, but feel free to adapt it to suit your needs. Whether you’re a developer or DevOps engineer, this guide should provide you with clear steps to achieve a similar setup.

S3 Setup

I’m using a private S3 bucket to store the files generated by Astro. By using CloudFront’s Origin Access Control (OAC), I can securely fetch assets from the bucket without exposing it publicly. This approach minimizes the risk of accidentally leaking sensitive files—a significant reason for avoiding S3’s static website hosting feature.

Key Steps

  1. Create a Private S3 Bucket: This bucket will store your website files.
  2. Attach a Bucket Policy: The policy allows CloudFront to access the bucket securely.
data "aws_iam_policy_document" "cloudfront" {
statement {
sid = "CloudFrontOAItoS3"
actions = ["s3:GetObject"]
effect = "Allow"
resources = ["${module.website.s3_bucket_arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
values = [module.cdn.cloudfront_distribution_arn]
variable = "AWS:SourceArn"
}
}
}
module "website" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "4.3.0"
acl = "private"
bucket = "resume.live.bachi.biz"
force_destroy = true
attach_policy = true
policy = data.aws_iam_policy_document.cloudfront.json
control_object_ownership = true
object_ownership = "BucketOwnerPreferred"
}

CloudFront Setup

CloudFront serves as a global content delivery network (CDN), caching content at edge locations for faster delivery to users worldwide. This is especially important for improving performance for users in distant regions. To reduce costs, you can adjust the price_class parameter to limit caching to specific regions. However, I used PriceClass_All to ensure global coverage. Key Configuration Points:

  • Default Root Object: Set this to index.html to avoid access errors for the root page.
  • Origin Access Control: Ensures secure access to the S3 bucket.
module "cdn" {
source = "terraform-aws-modules/cloudfront/aws"
version = "4.0.0"
comment = "Resume Page"
enabled = true
http_version = "http2and3"
is_ipv6_enabled = true
price_class = "PriceClass_All"
retain_on_delete = false
default_root_object = "index.html"
create_origin_access_control = true
default_cache_behavior = {
target_origin_id = "website_s3"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_name = "Managed-CachingOptimized"
origin_request_policy_name = "Managed-UserAgentRefererHeaders"
response_headers_policy_name = "Managed-SimpleCORS"
}
origin = {
website_s3 = {
domain_name = module.website.s3_bucket_bucket_regional_domain_name
origin_access_control = "s3"
}
}
}

Fixing Subfolder Access

When hosting static websites, one common issue is ensuring proper handling of subfolder URLs (e.g., /about/). Without additional configuration, accessing such URLs might result in an error since S3 doesn’t recognize them as valid objects. To resolve this, I implemented a CloudFront function that appends index.html where necessary.

Here’s the function:

function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}

And here the terraform code:

resource "aws_cloudfront_function" "routing" {
code = file("functions/routing.js")
name = "routing"
runtime = "cloudfront-js-1.0"
}
# Add to module "cdn"
default_cache_behavior = {
# ...
function_association = {
viewer-request = {
function_arn = aws_cloudfront_function.routing.arn
}
}
}

This configuration ensures that all subfolder requests are properly routed to the correct files, mimicking the behavior of .htaccess on Apache servers.

Custom Error Pages

By default, S3 returns a 403 Access Denied error for missing files, which isn’t user-friendly. To address this, we configure CloudFront to return a custom 404 page instead. This improves the user experience by displaying a meaningful error message when a page isn’t found.

custom_error_response = [{
error_code = 404
response_code = 404
response_page_path = "/404.html"
}, {
error_code = 403
response_code = 404
response_page_path = "/404.html"
}]

Setting Up a Custom Domain

A custom domain provides a professional touch and makes your blog more accessible. To enable this, we use AWS Certificate Manager (ACM) to issue an SSL certificate. Note that the certificate must be provisioned in the us-east-1 region for CloudFront to use it.

module "acm" {
source = "terraform-aws-modules/acm/aws"
version = "5.1.1"
providers = {
aws = aws.us-east-1
}
validation_method = "EMAIL"
domain_name = local.domain_name
subject_alternative_names = [
"*.${local.domain_name}",
]
}

Integrate with CloudFront

module "cdn" {
# ...
aliases = ["bachi.biz", "www.bachi.biz"]
viewer_certificate = {
acm_certificate_arn = module.acm.acm_certificate_arn
ssl_support_method = "sni-only"
}
}

Finally, update your DNS settings to point to the CloudFront distribution using CNAME records. Depending on your DNS provider, propagation may take some time, but once complete, your custom domain will be live and secured.

Conclusion

With S3, CloudFront, and Terraform, you can create a cost-effective, scalable, and secure hosting solution for your static site. The combination of these AWS services ensures optimal performance while providing full control over your deployment.

Although setting up this infrastructure may seem complex initially, the benefits of flexibility, cost savings, and performance optimization are well worth the effort. I hope this guide helps you deploy your own project successfully!

And here is the full configuration for this:

provider "aws" {
}
provider "aws" {
alias = "us-east-1"
region = "us-east-1"
}
data "aws_iam_policy_document" "cloudfront" {
statement {
sid = "CloudFrontOAItoS3"
actions = ["s3:GetObject"]
effect = "Allow"
resources = ["${module.website.s3_bucket_arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
values = [module.cdn.cloudfront_distribution_arn]
variable = "AWS:SourceArn"
}
}
}
module "website" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "4.3.0"
acl = "private"
bucket = "resume.live.bachi.biz"
force_destroy = true
attach_policy = true
policy = data.aws_iam_policy_document.cloudfront.json
control_object_ownership = true
object_ownership = "BucketOwnerPreferred"
}
module "acm" {
source = "terraform-aws-modules/acm/aws"
version = "5.1.1"
providers = {
aws = aws.us-east-1
}
validation_method = "EMAIL"
domain_name = local.domain_name
subject_alternative_names = [
"*.${local.domain_name}",
]
}
resource "aws_cloudfront_function" "routing" {
code = file("functions/routing.js")
name = "routing"
runtime = "cloudfront-js-1.0"
}
module "cdn" {
source = "terraform-aws-modules/cloudfront/aws"
version = "4.0.0"
aliases = ["bachi.biz", "www.bachi.biz"]
comment = "Resume Page"
enabled = true
staging = false # If you want to create a staging distribution, set this to true
http_version = "http2and3"
is_ipv6_enabled = true
price_class = "PriceClass_All"
retain_on_delete = false
wait_for_deployment = false
create_origin_access_control = true
default_root_object = "index.html"
default_cache_behavior = {
target_origin_id = "website_s3"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
use_forwarded_values = false
cache_policy_name = "Managed-CachingOptimized"
origin_request_policy_name = "Managed-UserAgentRefererHeaders"
response_headers_policy_name = "Managed-SimpleCORS"
function_association = {
# Valid keys: viewer-request, viewer-response
viewer-request = {
function_arn = aws_cloudfront_function.routing.arn
}
}
}
origin = {
website_s3 = {
domain_name = module.website.s3_bucket_bucket_regional_domain_name
origin_access_control = "s3"
}
}
viewer_certificate = {
acm_certificate_arn = module.acm.acm_certificate_arn
ssl_support_method = "sni-only"
}
custom_error_response = [{
error_code = 404
response_code = 404
response_page_path = "/404.html"
}, {
error_code = 403
response_code = 404
response_page_path = "/404.html"
}]
}