Serve Static Site from Private S3 Bucket

2025-12-11, Thu

There are a few options to serve static site from private S3 bucket, one of which is to use CloudFront.

In order to do it manually, we need to

And it's done — Unless you want to access the site with a custom subdomain (with the main domain registered with Route 53 already), then we also need to:

After everything is done properly, we should be able to visit custom-domain-name to access our file.

We could use tool like terraform to automate steps above. These steps could be splitted into one root module and three submodules, with a directory structure like this:

They will be explained in detail one by one:

1. The Root Module

The input of root module requires the existing main domain (i.e. hosted_zone) and custom subdomain name, as shown below

# ref: ./variables.tf
variable "existing_zone_name" {
  description = "exsting zone name"
  type = string
  default = "tangwenfei.org"
}

variable "custom_domain_name" {
  description = "custom CNAME record name"
  type = string
  default = "mec"
}

As for the root entry, it handles ACM Certificate creation and invokes other submodules, e.g.

# ref: ./main.tf
# the current working region    
provider "aws" {
  region  = "us-west-1"
  profile = "mec-profile"
}

# the special region for ACM Certificate
provider "aws" {
  region = "us-east-1"
  profile = "mec-profile"
  alias = "us_east_1"
}

locals {
  full_domain_name = "${var.custom_domain_name}.${var.existing_zone_name}"
}

# Step 1/3: Request the ACM certificate from us-east-1 region
resource "aws_acm_certificate" "custom_domain_cert" {
  provider = aws.us_east_1
  domain_name = local.full_domain_name
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

# Step 2/3: Add ACM Cert Validation record in Route 53, done in ./modules/aws-route53-website

# Step 3/3: Wait for the certificat to be validated,
# i.e. Certificate status changed from "Pending Validation" to "Issued"
resource "aws_acm_certificate_validation" "cert_validation" {
  provider = aws.us_east_1
  certificate_arn = aws_acm_certificate.custom_domain_cert.arn

  # theres's typically only one record for each manually created ACM Certificate
  validation_record_fqdns = [for fqdn in module.website_route53.cert_record_fqdns : fqdn]
}

module "website_s3_bucket" {
  source = "./modules/aws-s3-static-website"

  bucket_name = "mec-bucket-2025"

  cloudfront_arn = module.website_cloudfront.cloudfront_arn

  tags = {
    Terraform   = "true"
    Environment = "dev"
  }
}

module "website_cloudfront" {
  source = "./modules/aws-cloudfront-website"

  oac_name        = "cloudfront-oac-for-s3-mec-bucket-2025"
  oac_description = "OAC for CloudFront to access S3"
  root_object     = "index.html"

  domain_name = module.website_s3_bucket.regional_domain_name
  bucket_id = module.website_s3_bucket.id
  custom_full_domain = local.full_domain_name

  # This ARN is supposed to be the same with aws_acm_certificate.custom_domain_cert.arn
  acm_cert_arn = aws_acm_certificate_validation.cert_validation.certificate_arn
}

module "website_route53" {
  source = "./modules/aws-route53-website"

  zone_name = var.existing_zone_name
  custom_domain_name = var.custom_domain_name
  cloudfront_domain_name = module.website_cloudfront.domain_name
  cloudfront_hosted_zone_id = module.website_cloudfront.hosted_zone_id
  acm_cert_domain_validation_options = aws_acm_certificate.custom_domain_cert.domain_validation_options
}

As for the output of the root module, it contains the s3 bucket name, the CloudFront domain, and our custom subdomain name.

# ref: ./outputs.tf
output "cloudfront_domain_name" {
  description = "Domain name of CloudFront"
  value = module.website_cloudfront.domain_name
}

output "website_domain_name" {
  description = "custom domain name of the static site"
  value = "https://${var.custom_domain_name}.${var.existing_zone_name}"
}

Now move on to the submodules, starting with S3.

2. The S3 Module

The S3 module requires bucket name and CloudFront ARN as input, e.g.

# ref: ./modules/aws-s3-static-website/variables.tf
variable "bucket_name" {
  description = "Name of the s3 bucket. Must be unique"
  type        = string
}

# Note: it's the CloudFront ARN that is needed here, not CloudFront OAC ARN
variable "cloudfront_arn" {
  description = "the CloudFront ARN"
  type = string

}

variable "tags" {
  description = "Tags to set on the bucket."
  type        = map(string)
  default     = {}
}

The main entry of the S3 module creates a S3 bucket along with proper bucket policy for CloudFront access.

 # ref: ./modules/aws-s3-static-website/main.tf
 resource "aws_s3_bucket" "s3_bucket" {
   bucket = var.bucket_name
   force_destroy = true # This line allows destruction of a non-empty bucket

   tags = var.tags
 }

 resource "aws_s3_bucket_website_configuration" "s3_bucket" {
   bucket = aws_s3_bucket.s3_bucket.id

   index_document {
     suffix = "index.html"
   }

   error_document {
     key = "error.html"
   }
 }

 resource "aws_s3_bucket_policy" "s3_bucket" {
   bucket = aws_s3_bucket.s3_bucket.id

   policy = jsonencode({
     Version = "2012-10-17"
     Statement = [
       {
         Effect = "Allow",
         Principal = {
           Service = "cloudfront.amazonaws.com"
         },
         Action   = "s3:GetObject",
         Resource = "${aws_s3_bucket.s3_bucket.arn}/*",
         Condition = {
           StringEquals = {
             "AwS:SourceArn" = var.cloudfront_arn
           }
         }
       },
     ]
   }) 
}

The output of this module includes id, name, and regional_domain_name of the newly created S3 bucket.

# ref: ./modules/aws-s3-static-website/outputs.tf
output "name" {
  description = "Name (id) of the bucket"
  value       = aws_s3_bucket.s3_bucket.id
}

output "regional_domain_name" {
  description = "S3 bucket region"
  value = aws_s3_bucket.s3_bucket.bucket_regional_domain_name
}

output "id" {
  description = "S3 bucket id"
  value = aws_s3_bucket.s3_bucket.id
}

3. The CloudFront Module

The CloundFront module has following four required input parameters:

  • the S3 bucket regional domain name for content servicing
  • our custom domain name that serves as alternate domain name
  • the ACM Certificaet ARN for the alternate domain name

along with some other optional parameters:

# ref: ./modules/aws-cloudfront-website/variables.tf
variable "oac_name" {
  description = "Name of the CloudFront OAC(Origin Access Control) configuration"
  type        = string
}

variable "oac_description" {
  description = "Description of the CloudFront OAC configuration"
  type        = string
}

variable "root_object" {
  description = "root object for CloudFront distribution"
  type        = string
  default = "index.html"
}

variable "domain_name" {
  description = "S3 bucket regional domain name"
  type = string
}

variable "bucket_id" {
  description = "S3 Bucket Id"
  type = string
}

variable "custom_full_domain" {
  description = "custom FULL domain name in Route 53, i.e. your own domain to access the site"
  type = string
}

variable "acm_cert_arn" {
  description = "ACM Certificate ARN"
  type = string
}

The main entry of this module creates an OAC rule and CloudFront distribution that accesses S3 bucket, with alias that points to custom domain and proper viewer certificates.

# ref: ./modules/aws-cloudfront-website/main.tf
# Origin Access Control (OAC)
resource "aws_cloudfront_origin_access_control" "s3_oac" {
  name                              = var.oac_name
  description                       = var.oac_description
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}


resource "aws_cloudfront_distribution" "s3_distribution" {
  origin {
    domain_name              = var.domain_name
    origin_id                = "s3-origin-${var.bucket_id}"
    origin_access_control_id = aws_cloudfront_origin_access_control.s3_oac.id
  }

  enabled             = true
  is_ipv6_enabled     = true
  comment             = "CloudFront distribution for s3 static website"
  default_root_object = var.root_object


  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "s3-origin-${var.bucket_id}"
    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
    compress               = true
    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  aliases = [
    var.custom_full_domain
  ]

  viewer_certificate {
    acm_certificate_arn = var.acm_cert_arn
    ssl_support_method = "sni-only"
  }

  tags = {
    Environment = "mec-custom-env"
    ManagedBy   = "Terraform"
  }
}

The CloudFront module outputs the following attributes of the distrbution resource:

  • arn
  • domain_name
  • hosted_zone_id
# ref: ./modules/aws-cloudfront-website/outputs.tf
output "cloudfront_arn" {
  description = "the CloudFront ARN for S3 bucket"
  value = aws_cloudfront_distribution.s3_distribution.arn
}

output "domain_name" {
  description = "The domain name of published CloudFront site"
  value = aws_cloudfront_distribution.s3_distribution.domain_name
}

output "hosted_zone_id" {
  description = "Hosted Zone Id of CloudFront site"
  value = aws_cloudfront_distribution.s3_distribution.hosted_zone_id
}

4. The Route 53 Module

The Route 53 module requires both the custom domain and CloudFront distribution domain, along with the ACM Certificate validation records.

# ref: .//modules/aws-route53-website/variables.tf
variable "zone_name" {
  description = "exsting zone name"
  type = string
}

variable "custom_domain_name" {
  description = "custom CNAME record name"
  type = string
}

variable "cloudfront_domain_name" {
  description = "CloudFront domain name"
  type = string
}

variable "cloudfront_hosted_zone_id" {
  description = "CloudFront hosted zone"
  type = string
}

variable "acm_cert_domain_validation_options" {
  description = "List of domain validation options from the ACM Certificate"
  # type = any
  type = list(object({
    domain_name = string
    resource_record_name = string
    resource_record_type = string
    resource_record_value = string
  }))
}

The main entry of the Route 53 module adds two types of records:

  • an A type alias record for our custom domain that routes traffic to CloudFront distribution, and
  • a validation record for validating the ACM issued certificate
# ref: .//modules/aws-route53-website/main.tf
locals {
  full_domain_name = "${var.custom_domain_name}.${var.zone_name}"
}

# retrieve info of existing zone, e.g. tangwenfei.org
data "aws_route53_zone" "existing_hosted_zone" {
  name = var.zone_name
}

resource "aws_route53_record" "custom_site_domain" {
  zone_id = data.aws_route53_zone.existing_hosted_zone.zone_id
  name = local.full_domain_name
  # name = var.custom_domain_name
  type = "A"

  alias {
    name = var.cloudfront_domain_name
    zone_id = var.cloudfront_hosted_zone_id
    evaluate_target_health = false
  }
}

# ============================================================
# Add DNS record for the ACM Ceritificate

# Step 1/3: Request ACM Certififcate from us-east-1 region, which is done in the root module
# Step 2/3: Add ACM Certificate Validation record into Route 53
# Step 3/3: Wait for the ACM Certificate to become Issued. Done in the root module

resource "aws_route53_record" "cert_validation_record" {
  for_each = {
    for dvo in var.acm_cert_domain_validation_options : dvo.domain_name => dvo
  }

  name = each.value.resource_record_name
  records = [each.value.resource_record_value]
  type = each.value.resource_record_type
  zone_id = data.aws_route53_zone.existing_hosted_zone.zone_id
  ttl = 60
}

This module outputs the fully qualified domain name for validation of ACM issued certificate, i.e. checking that its status has been chagned from "Pending Validation" to "Issued".

# ref: .//modules/aws-route53-website/output.tf
output "cert_record_fqdns" {
  description = "The Route53 validation records added for ACM Certificate"
  value = values(aws_route53_record.cert_validation_record)[*].fqdn
}

5. Conclusion

And that's it. To run the plan and check the result, run the following sample script:

#!/bin/sh
# current work directory .
terraform init
terraform plan --out out/plan.out
terraform apply --auto-aprove out/plan.out
aws --profile mec-profile s3 cp /path/to/index.html s3://mec-bucket-2025
curl https://mec.tangwenfei.org
terraform destroy

6. References