Secure Static Website on AWS S3 & CloudFront
This sets up a static site on S3 kept private behind CloudFront with Origin Access Control, ACM-managed TLS, and a deploy step that invalidates the cache correctly, the secure pattern instead of the public-bucket shortcut.
Bucket setup: private by default
The bucket should never be publicly readable or have static website hosting enabled (that feature only serves plain HTTP). It exists purely as private storage that CloudFront is allowed to read.
resource "aws_s3_bucket" "site" {
bucket = "www.example-consulting.com"
}
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
}Origin Access Control is the mechanism AWS currently recommends for letting CloudFront read from a private bucket. It replaces Origin Access Identity (OAI), which AWS now labels legacy. OAI still works, but new setups should use OAC. OAC signs every request CloudFront makes to S3 with SigV4.
resource "aws_cloudfront_origin_access_control" "site" {
name = "site-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}The bucket policy grants read access to the CloudFront service principal only. The AWS:SourceArn condition limits it to this distribution, so no other distribution (including one in someone else's account) can use the bucket as an origin. Without this policy, OAC signs requests that S3 then refuses, and every page returns 403.
data "aws_iam_policy_document" "site" {
statement {
sid = "AllowCloudFrontServicePrincipal"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.site.json
depends_on = [aws_s3_bucket_public_access_block.site]
}There's a chicken-and-egg problem here. The distribution ARN doesn't exist until the distribution is created, and the distribution needs the OAC to exist first. Terraform works out the order from the references above. The bucket policy depends on the distribution, so Terraform creates the OAC, then the distribution, then the policy. The public access block stays fully on. A policy scoped to a service principal with a SourceArn condition doesn't count as "public", so block_public_policy doesn't reject it. Because the policy only grants s3:GetObject, a missing key comes back as 403 rather than 404. S3 won't reveal whether an object exists unless the caller has s3:ListBucket.
Clean URLs: rewriting to index.html
A private S3 bucket behind OAC is served through the S3 REST endpoint, which has no concept of index documents. default_root_object only fixes /. A request for /about/ or /about asks S3 for a key that doesn't exist and gets a 403. If your static export uses directory-style output (Next.js with trailingSlash: true writes out/about/index.html), you need a small viewer-request CloudFront Function to append index.html:
// rewrite-index.js
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
// /about/ -> /about/index.html
request.uri += 'index.html';
} else if (!uri.split('/').pop().includes('.')) {
// /about -> /about/index.html (no file extension in the last segment)
request.uri += '/index.html';
}
return request;
}resource "aws_cloudfront_function" "rewrite_index" {
name = "site-rewrite-index"
runtime = "cloudfront-js-2.0"
publish = true
code = file("${path.module}/rewrite-index.js")
}If your framework exports flat files instead (about.html), change the rewrite to append .html. Either way, something has to map the clean URL to a real key, because S3 won't do it.
CloudFront distribution
The distribution connects the bucket, the OAC and the rewrite function. It also configures TLS, the HTTP-to-HTTPS redirect and the default document.
resource "aws_cloudfront_distribution" "site" {
enabled = true
is_ipv6_enabled = true
http_version = "http2and3"
default_root_object = "index.html"
aliases = ["www.example-consulting.com"]
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "s3-site-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
target_origin_id = "s3-site-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # AWS managed: CachingOptimized
response_headers_policy_id = aws_cloudfront_response_headers_policy.security.id
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.rewrite_index.arn
}
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate_validation.site.certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}viewer_protocol_policy = "redirect-to-https" enforces TLS for visitors. Without it, CloudFront will serve plain HTTP. The certificate ARN comes from aws_acm_certificate_validation rather than from the certificate itself, so Terraform waits until ACM has issued the certificate before attaching it. If you point at aws_acm_certificate.site.arn directly, the first apply fails because the certificate is still pending validation.
Security headers and HTTP/3
S3 doesn't send security headers, and a static site has no server of its own to add them. CloudFront can add them to every response with a response headers policy, which the default cache behavior above references.
resource "aws_cloudfront_response_headers_policy" "security" {
name = "static-site-security-headers"
security_headers_config {
strict_transport_security {
access_control_max_age_sec = 31536000
include_subdomains = true
preload = false
override = true
}
content_type_options {
override = true
}
frame_options {
frame_option = "DENY"
override = true
}
referrer_policy {
referrer_policy = "strict-origin-when-cross-origin"
override = true
}
}
custom_headers_config {
items {
header = "Permissions-Policy"
value = "camera=(), microphone=(), geolocation=()"
override = true
}
}
}Strict-Transport-Security tells browsers to use HTTPS for the domain for a year, so after the first visit they skip the HTTP-to-HTTPS redirect. include_subdomains extends it to subdomains of the host that sends the header. If you also serve the apex domain from this distribution, that covers every subdomain of your domain, so only turn it on once all of them serve HTTPS. Leave preload off until you're sure, because getting a domain removed from the browsers' preload list takes months.
X-Content-Type-Options: nosniff stops browsers from guessing a file's type from its content, which matters on a bucket where a wrong Content-Type at upload is easy to miss. X-Frame-Options: DENY stops other sites from showing your pages in a frame. Use SAMEORIGIN if you frame your own pages. With strict-origin-when-cross-origin, a visitor who follows a link to another site sends only your domain, without the full URL.
override = true makes CloudFront replace a header the origin already sent. With a private S3 bucket that's the same as adding it, but it keeps the policy in charge if you later add an origin that sets its own headers.
The policy leaves out a Content-Security-Policy on purpose. A CSP depends on the scripts, fonts and embeds your pages load, and a wrong one breaks the site for some visitors without any error you'd notice. Start with a Content-Security-Policy-Report-Only custom header, watch the browser console for violations, and switch to enforcing once there are none. AWS also has a managed SecurityHeadersPolicy. It's a quick start, but you can't change its values, so a policy you own is easier to tighten later.
http_version = "http2and3" on the distribution turns on HTTP/3 over QUIC. CloudFront advertises it with an Alt-Svc header. Browsers that support it switch for later connections, and the rest stay on HTTP/2. The gain shows mostly on mobile and unreliable networks, where QUIC's faster connection setup and lack of head-of-line blocking help most. To check both once the distribution has deployed:
curl -sI https://www.example-consulting.com | grep -iE 'alt-svc|strict-transport|x-content-type|x-frame|referrer-policy|permissions-policy'ACM certificate and custom domain
CloudFront only accepts ACM certificates issued in us-east-1, whatever region the rest of your infrastructure is in, which is why the provider is aliased. This assumes the hosted zone for your domain already exists in Route 53.
provider "aws" {
region = "eu-west-1" # your main region: the bucket lives here
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
data "aws_route53_zone" "site" {
name = "example-consulting.com"
private_zone = false
}
resource "aws_acm_certificate" "site" {
provider = aws.us_east_1
domain_name = "www.example-consulting.com"
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.site.domain_validation_options : dvo.domain_name => dvo
}
allow_overwrite = true
zone_id = data.aws_route53_zone.site.zone_id
name = each.value.resource_record_name
type = each.value.resource_record_type
records = [each.value.resource_record_value]
ttl = 60
}
resource "aws_acm_certificate_validation" "site" {
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.site.arn
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}
resource "aws_route53_record" "site" {
for_each = toset(["A", "AAAA"])
zone_id = data.aws_route53_zone.site.zone_id
name = "www.example-consulting.com"
type = each.key
alias {
name = aws_cloudfront_distribution.site.domain_name
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
evaluate_target_health = false
}
}The A and AAAA alias records point the domain at the distribution (AAAA because IPv6 is enabled on it). You now have a custom domain with a managed certificate that renews itself, so there are no cert files to rotate by hand. ACM keeps renewing it as long as the validation CNAME stays in the zone, which is another reason to manage that record in Terraform.
CloudFront caches error responses too. By default, a 403 or 404 from the origin is cached for 10 seconds (the error caching minimum TTL), or for whatever error_caching_min_ttl you set in a custom_error_response. If you're debugging an access-denied error right after a config change, wait for that window to pass before deciding the fix didn't work, or invalidate /* if you've configured a longer error TTL. The distribution itself also takes a few minutes to deploy after terraform apply returns.
The deploy step: sync and invalidate
The "just upload to S3" tutorials skip this step, which is why a pushed fix often doesn't show up on reload: CloudFront's edge caches don't know your files changed until you tell them.
#!/usr/bin/env bash
set -euo pipefail
BUCKET="s3://www.example-consulting.com"
# 1. Hashed build assets: filenames change on every build, safe to cache for a year.
# No --delete, so visitors holding an older HTML page can still load its chunks.
aws s3 sync ./out/_next/static "$BUCKET/_next/static" \
--cache-control "public,max-age=31536000,immutable"
# 2. Everything else that isn't HTML (favicon, robots.txt, sitemap, images): short cache
aws s3 sync ./out "$BUCKET" \
--delete \
--exclude "_next/static/*" --exclude "*.html" \
--cache-control "public,max-age=3600"
# 3. HTML last, so it never references assets that aren't uploaded yet.
# Browsers revalidate it on every visit, and the edge keeps it until the invalidation below.
aws s3 sync ./out "$BUCKET" \
--delete \
--exclude "*" --include "*.html" \
--cache-control "public,max-age=0,s-maxage=31536000,must-revalidate"
aws cloudfront create-invalidation \
--distribution-id "$DISTRIBUTION_ID" \
--paths "/*"The script runs three separate syncs because the files need different cache lifetimes. Only the fingerprinted files under _next/static/ (the JS/CSS bundles from a Next.js static export; use your framework's hashed-asset directory otherwise) get a year-long immutable lifetime, since their filenames change on every build. Files that keep the same name between builds, like favicon.ico, robots.txt, sitemap.xml or images in public/, get a short cache. Marking them immutable would pin old versions in browsers for a year, and no invalidation reaches a browser cache. HTML files get max-age=0,must-revalidate, so browsers check for a new version on every visit, and s-maxage=31536000, so CloudFront keeps them at the edge until the next deploy invalidates them. Without s-maxage, the edge would keep HTML for only a second (the minimum TTL of the cache policy) and fetch it from S3 on almost every request. For HTML, the invalidation at the end of the script is what makes a deploy visible.
Invalidations aren't free past the first 1,000 paths a month, but at personal or small-business deploy frequency, /* is cheap enough that computing a precise changed-file list isn't worth it. Optimize only once you're deploying dozens of times a day.
Cache headers and behaviors
The distribution's cache policy and the Cache-Control headers set during sync are meant to work together. Under the managed CachingOptimized policy, CloudFront respects origin Cache-Control headers within the policy's minimum and maximum TTL (1 second to one year). It only falls back to its default TTL of one day when the origin doesn't send one, which is why the sync step sets explicit headers on every object. When both are present, CloudFront uses s-maxage for its own cache and browsers use max-age. The response headers policy from the security section only changes the headers sent to browsers. It doesn't change how long CloudFront keeps anything in its cache.
Putting it together
The Terraform here takes a couple of hours to write once; after that it's a module you copy and rename.
Want to actually run this in production?
This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.
This tutorial
- Core architecture & key concepts
- Illustrative code snippets
- The reasoning behind each decision
1:1 mentoring
- Working sessions on your own environment
- Direct answers to the edge cases you're hitting
- Feedback on your actual implementation
- Ongoing support as you build it out
Related Tutorials
Event-Driven Apps with Lambda & EventBridge
Design an event-driven pipeline on Lambda and EventBridge that handles poison messages, retries and cold starts.
Hardening Production AWS EKS Clusters
Three controls that separate a hardened EKS cluster from a default one: IRSA, NetworkPolicies and OPA Gatekeeper.
Nginx as a Reverse Proxy with Let's Encrypt SSL
A hardened Nginx reverse proxy in front of your app, with Let's Encrypt certificates that actually auto-renew instead of silently expiring.