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
- Create a S3 bucket that blocks all public access, upload site files to it
- Create a CloudFront distribution, along with a new OAC Setting (Origin Access Control). Copy the generated S3 bucket policy
- Go to S3 bucket Permissions tab and apply the copied CloudFront OAC policy
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:
- Request a ACM Certificate for our subdomain name in the
us-east-1region - Add the ACM Certificate validation record into Route 53 and wait for Certificate status becomes "Issued"
- In CloudFront distribution setting, add alternate domain name that points to custom domain, along with SSL certificate that just got issued by ACM
- In Route 53 hosted zone, add another alias A record that routes traffic to the CloudFront distribution
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:
- ./
- ./modules/aws-s3-static-website
- ./modules/aws-cloudfront-website
- ./modules/aws-route53-website
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:
arndomain_namehosted_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
acm_certificate: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificateacm_certificate_validation: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate_validations3_bucket: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_buckets3_bucket_website_configuration: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_website_configurations3_bucket_policy: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_policycloudfront_oac: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_origin_access_controlcloudfront_distribution: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distributionroute53_zone: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_zoneroute53_record: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record