AWSS3CloudFrontBeginnerWeb HostingSecurityIaC

Secure Static Website on AWS S3 & CloudFront

Jerry RajaonarivoUpdated Oct 16, 202611 min read

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.

hcl
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.

hcl
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.

hcl
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:

js
// 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;
}
hcl
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.

hcl
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.

hcl
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:

bash
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.

hcl
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.

bash
#!/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.

GO FURTHER

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
Get in TouchReply within 1 business day.

Related Tutorials