Hook
Deploying a static website to production requires more than just uploading files to storage—it needs a content delivery network for global performance, HTTPS for security, and DNS routing to tie it all together. Static website hosting with Terraform in Terraform & IaC lets us define this entire stack as code, making it reproducible, auditable, and version-controlled alongside the rest of our infrastructure.
Why this matters for production static sites
We've all seen the temptation to manually create an S3 bucket, grant it public access, and call it done. That approach works for demos, but it leaves us vulnerable to misconfiguration, makes scaling across environments painful, and offers no paper trail of who changed what. When we combine S3 for storage, CloudFront for caching and delivery, Route 53 for DNS, and ACM for HTTPS certificates—all defined in Terraform—we get a robust, repeatable pattern suitable for production workloads like marketing sites, documentation portals, and single-page applications.
Private bucket with Origin Access Control
The foundation of a secure static site is a private S3 bucket. No public ACLs, no public access blocks disabled—just a bucket that holds our content and nothing else. We then use CloudFront's Origin Access Control (OAC) to grant CloudFront exclusive permission to read from the bucket.
resource "aws_s3_bucket" "website" {
bucket = "my-site-${data.aws_caller_identity.current.account_id}"
}
resource "aws_s3_bucket_versioning" "website" {
bucket = aws_s3_bucket.website.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "website" {
bucket = aws_s3_bucket.website.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_cloudfront_origin_access_control" "website" {
name = "website-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
data "aws_iam_policy_document" "s3_policy" {
statement {
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.website.arn}/*"]
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.website.arn]
}
}
}
resource "aws_s3_bucket_policy" "website" {
bucket = aws_s3_bucket.website.id
policy = data.aws_iam_policy_document.s3_policy.json
}This setup ensures that only CloudFront can access the bucket contents, and only when requests come through the specific distribution. The versioning enables rollback if a deployment goes wrong. In a real scenario, we'd also configure an S3 lifecycle policy to clean up old versions after 90 days.
CloudFront distribution with caching strategy
CloudFront is where the magic happens. It caches our content at edge locations worldwide, serves it over HTTPS, and can rewrite URLs. We configure a default root object so requests to the domain root return index.html, set custom error pages for 404s and 403s, and define cache behavior that's appropriate for static assets.
resource "aws_cloudfront_distribution" "website" {
origin {
domain_name = aws_s3_bucket.website.bucket_regional_domain_name
origin_id = "s3-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.website.id
}
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-origin"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
compress = true
}
cache_behaviors {
path_pattern = "/assets/*"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-origin"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "https-only"
min_ttl = 0
default_ttl = 31536000
max_ttl = 31536000
compress = true
}
custom_error_response {
error_code = 404
error_caching_min_ttl = 300
response_code = 404
response_page_path = "/404.html"
}
custom_error_response {
error_code = 403
error_caching_min_ttl = 300
response_code = 404
response_page_path = "/404.html"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = false
acm_certificate_arn = aws_acm_certificate.website.arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}Notice the cache behavior split: HTML and dynamic content get a short 1-hour TTL, while assets like CSS and JavaScript under /assets/* get a one-year TTL with versioning in the filename. This is a real-world strategy we use across e-commerce and SaaS platforms. The custom error responses also handle the classic SPA routing issue where a 403 (forbidden, because S3 doesn't list directories) should return our index.html so the front-end router can take over.
ACM certificate and DNS routing
HTTPS is non-negotiable, and AWS Certificate Manager (ACM) makes it free and simple. One gotcha: CloudFront certificates must be in us-east-1 regardless of your primary region.
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
resource "aws_acm_certificate" "website" {
provider = aws.us_east_1
domain_name = "example.com"
validation_method = "DNS"
subject_alternative_names = [
"www.example.com"
]
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.website.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
type = dvo.resource_record_type
record = dvo.resource_record_value
}
}
zone_id = aws_route53_zone.website.zone_id
name = each.value.name
type = each.value.type
ttl = 60
records = [each.value.record]
}
resource "aws_acm_certificate_validation" "website" {
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.website.arn
timeouts {
create = "5m"
}
depends_on = [aws_route53_record.cert_validation]
}
resource "aws_route53_record" "website_alias" {
zone_id = aws_route53_zone.website.zone_id
name = "example.com"
type = "A"
alias {
name = aws_cloudfront_distribution.website.domain_name
zone_id = aws_cloudfront_distribution.website.hosted_zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "website_www_alias" {
zone_id = aws_route53_zone.website.zone_id
name = "www.example.com"
type = "A"
alias {
name = aws_cloudfront_distribution.website.domain_name
zone_id = aws_cloudfront_distribution.website.hosted_zone_id
evaluate_target_health = false
}
}We define the certificate with DNS validation, create Route 53 records to prove domain ownership, and wait for validation to complete before proceeding. The alias records then point both the root domain and www subdomain to CloudFront. This pattern is bulletproof: if we ever need to change the certificate or distribution, Terraform will coordinate the updates safely.
Deploy: Uploading builds and cache invalidation
Once infrastructure is in place, we need a process to upload new builds and keep the cache fresh. We typically do this from CI/CD—GitHub Actions, GitLab CI, or AWS CodePipeline.
resource "aws_s3_object" "website_files" {
for_each = fileset("${path.module}/dist", "**")
bucket = aws_s3_bucket.website.id
key = each.value
source = "${path.module}/dist/${each.value}"
etag = filemd5("${path.module}/dist/${each.value}")
content_type = lookup(local.mime_types, regex("\\.[^.]+$", each.value), "application/octet-stream")
}
locals {
mime_types = {
".html" = "text/html"
".css" = "text/css"
".js" = "application/javascript"
".json" = "application/json"
".png" = "image/png"
".jpg" = "image/jpeg"
".svg" = "image/svg+xml"
".woff" = "font/woff"
".woff2" = "font/woff2"
}
}
resource "aws_cloudfront_invalidation" "website" {
distribution_id = aws_cloudfront_distribution.website.id
paths = ["/*"]
depends_on = [aws_s3_object.website_files]
}The fileset function automatically discovers all files in the dist directory, uploads them with correct MIME types, and triggers a CloudFront invalidation. In CI, we'd run terraform apply after building the site; Terraform detects changed ETags and re-uploads only what's needed. For even faster feedback in CD pipelines, we can use the AWS CLI directly:
#!/bin/bash
aws s3 sync dist/ s3://my-site-bucket/ \
--region us-east-1 \
--delete \
--cache-control "public, max-age=3600" \
--exclude "assets/*" \
--metadata "deploy-date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
aws s3 sync dist/assets/ s3://my-site-bucket/assets/ \
--region us-east-1 \
--cache-control "public, max-age=31536000, immutable" \
--metadata "deploy-date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
aws cloudfront create-invalidation \
--distribution-id E1234ABCD \
--paths "/*"This pattern ensures HTML refreshes within an hour while assets stay cached for a year, providing both freshness and performance.
Common mistakes
- Forgetting the us-east-1 provider alias. CloudFront only accepts ACM certificates from us-east-1. If you create the certificate in your primary region, the distribution won't find it.
- Invalidating the entire distribution on every deploy. CloudFront charges for invalidations beyond the first 100 per month. Be specific: invalidate only
/index.htmland/404.htmlif those are the only files that changed. - Setting cache TTLs without version hashing. If your asset filenames don't include a hash, long TTLs will serve stale CSS and JavaScript. Always hash your build output filenames.
- Missing the S3 bucket policy condition. Without the
AWS:SourceArncondition, any CloudFront distribution could access your bucket. Always tie OAC permissions to a specific distribution. - Not enabling S3 versioning. Versioning is cheap and lets you roll back a bad deployment in seconds by restoring an older object version.
Key takeaways
- Keep the S3 bucket private with public access blocks, and use CloudFront Origin Access Control to grant only CloudFront read permissions.
- Split cache behaviors by content type: short TTLs for HTML, long TTLs for versioned assets.
- Always provision ACM certificates in us-east-1 for CloudFront, and use DNS validation to automate the provisioning process.
- Coordinate S3 uploads and CloudFront invalidations in your CI pipeline to ensure consistency between what's uploaded and what's served.
- Use S3 versioning and lifecycle policies to keep storage costs manageable while retaining the ability to roll back.






Comments
Loading comments…