Project: Static Website on S3
Static sites are everywhere — marketing pages, documentation, single-page apps — and S3 plus CloudFront is the canonical AWS pattern for serving them cheaply and globally. In this project you will build the whole stack in Terraform: a private S3 bucket holding the files, a CloudFront distribution caching them at edge locations, an ACM certificate for HTTPS, and a Route 53 record pointing your domain at CloudFront. The result is a reusable, version-controlled deployment you can spin up for any domain in minutes. Everything here works identically with OpenTofu (tofu in place of terraform).
Architecture overview
The flow is simple: visitors hit your domain, Route 53 resolves it to CloudFront, CloudFront serves cached objects (or fetches them from S3 on a miss) over HTTPS using an ACM certificate. The S3 bucket itself stays private — only CloudFront can read it, via an Origin Access Control (OAC).
| Component | Purpose | Notes |
|---|---|---|
| S3 bucket | Stores the site files | Private; no public access |
| CloudFront | Global CDN + TLS termination | Uses OAC to read S3 |
| ACM certificate | HTTPS for your domain | Must live in us-east-1 |
| Route 53 | DNS for the domain | Alias record to CloudFront |
ACM certificates used by CloudFront must be created in the
us-east-1region, regardless of where the rest of your infrastructure lives. We handle this with a provider alias below.
Providers and variables
Define two AWS providers — one for your default region and an aliased one pinned to us-east-1 for ACM.
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}
provider "aws" {
region = var.region
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
variable "region" {
type = string
default = "eu-west-1"
}
variable "domain_name" {
type = string
description = "FQDN to serve the site from, e.g. www.example.com"
}
variable "zone_name" {
type = string
description = "Route 53 hosted zone, e.g. example.com"
}
The S3 origin bucket
Create the bucket and lock it down. Public access is fully blocked; CloudFront reaches it through OAC instead of public URLs.
resource "aws_s3_bucket" "site" {
bucket = var.domain_name
}
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_website_configuration" "site" {
bucket = aws_s3_bucket.site.id
index_document {
suffix = "index.html"
}
error_document {
key = "404.html"
}
}
ACM certificate with DNS validation
Request the certificate in us-east-1 and validate it automatically by writing the CNAME records into Route 53.
data "aws_route53_zone" "this" {
name = var.zone_name
}
resource "aws_acm_certificate" "cert" {
provider = aws.us_east_1
domain_name = var.domain_name
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
type = dvo.resource_record_type
record = dvo.resource_record_value
}
}
zone_id = data.aws_route53_zone.this.zone_id
name = each.value.name
type = each.value.type
records = [each.value.record]
ttl = 60
}
resource "aws_acm_certificate_validation" "cert" {
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.cert.arn
validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}
CloudFront distribution with OAC
The Origin Access Control signs requests so only CloudFront can read the bucket. The distribution serves over HTTPS and redirects HTTP to HTTPS.
resource "aws_cloudfront_origin_access_control" "site" {
name = "${var.domain_name}-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "site" {
enabled = true
default_root_object = "index.html"
aliases = [var.domain_name]
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "s3-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
target_origin_id = "s3-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # Managed-CachingOptimized
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate_validation.cert.certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}
Finally, attach a bucket policy that grants the distribution read access, and point DNS at CloudFront.
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowCloudFrontRead"
Effect = "Allow"
Principal = { Service = "cloudfront.amazonaws.com" }
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.site.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.site.arn
}
}
}]
})
}
resource "aws_route53_record" "alias" {
zone_id = data.aws_route53_zone.this.zone_id
name = var.domain_name
type = "A"
alias {
name = aws_cloudfront_distribution.site.domain_name
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
evaluate_target_health = false
}
}
Deploy and upload
Initialize, review the plan, then apply.
terraform init
terraform plan -var 'domain_name=www.example.com' -var 'zone_name=example.com'
terraform apply -var 'domain_name=www.example.com' -var 'zone_name=example.com'
Output:
Plan: 9 to add, 0 to change, 0 to destroy.
...
aws_cloudfront_distribution.site: Creation complete after 4m12s
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.
Once the distribution is deployed, push your built site to the bucket. CloudFront serves it within seconds.
aws s3 sync ./dist s3://www.example.com --delete
aws cloudfront create-invalidation \
--distribution-id E123EXAMPLE --paths "/*"
The first
applycan take several minutes because CloudFront propagates to every edge location. Subsequent content updates only require ans3 syncplus a cache invalidation — no Terraform run needed.
Best practices
- Keep the bucket private and rely exclusively on OAC; never re-enable public ACLs to “make it work.”
- Use AWS managed cache policies (like CachingOptimized) instead of hand-rolling legacy
forwarded_values, which is deprecated. - Pin the ACM provider to
us-east-1with an alias so the certificate is valid for CloudFront. - Set short TTLs on
index.html(or invalidate on deploy) so users see fresh content, while letting hashed assets cache long-term. - Store Terraform state remotely (S3 + DynamoDB lock, or a backend) so the deployment is collaborative and recoverable.
- Parameterize
domain_nameandzone_nameso the same configuration — or a module wrapping it — serves any site.