Deploying a 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 current AWS-recommended mechanism for letting CloudFront read from a private bucket, replacing the older, now-deprecated Origin Access Identity (OAI). OAC signs every request CloudFront makes to S3 using 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, scoped down by AWS:SourceArn so no other distribution can use it as an origin:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::www.example-consulting.com/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E1EXAMPLEID"
}
}
}
]
}There's a chicken-and-egg problem: the distribution ARN doesn't exist until the distribution is created, and the distribution needs the OAC to exist first. Terraform resolves this naturally through resource references: just make sure the bucket policy depends on the distribution, not the other way around.
CloudFront distribution
The distribution ties the bucket and OAC together, and configures TLS, HTTP-to-HTTPS redirection, and the default document.
resource "aws_cloudfront_distribution" "site" {
enabled = true
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"]
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # AWS managed: CachingOptimized
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate.site.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 happily serve plain HTTP. default_root_object makes / resolve to index.html, but not /about/ to /about/index.html; for that you need either a small CloudFront Function rewriting the URI, or a Next.js export with trailing-slash-less routes and matching S3 keys.
ACM certificate and custom domain
CloudFront only accepts ACM certificates issued in us-east-1, regardless of which region the rest of your infrastructure lives in.
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
resource "aws_acm_certificate" "site" {
provider = aws.us_east_1
domain_name = "www.example-consulting.com"
validation_method = "DNS"
}
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.site.domain_validation_options : dvo.domain_name => dvo
}
zone_id = 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
}Once validated, point the domain's Route53 alias record at the distribution and you have a custom domain with a managed, auto-renewing certificate: no cert files to rotate manually.
CloudFront caches error responses too: if your OAC or bucket policy is even slightly wrong, it'll cache the resulting 403 for the default TTL and keep serving it until you invalidate. When debugging an access-denied error right after a config change, invalidate /* before concluding the fix didn't work.
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
aws s3 sync ./out s3://www.example-consulting.com \
--delete \
--cache-control "public,max-age=31536000,immutable" \
--exclude "*.html"
aws s3 sync ./out s3://www.example-consulting.com \
--delete \
--cache-control "public,max-age=0,must-revalidate" \
--exclude "*" --include "*.html"
aws cloudfront create-invalidation \
--distribution-id "$DISTRIBUTION_ID" \
--paths "/*"Two syncs, not one. Hashed static assets (JS/CSS bundles from a Next.js static export) get a year-long cache lifetime, since their filenames change on every build. HTML files get must-revalidate instead, because they're what visitors see and need to update immediately. Without invalidation, index.html is exactly the file most likely to be stale right after a deploy.
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 work together, not against each other: under the managed CachingOptimized policy, CloudFront respects origin headers by default, falling back to its own TTL only when the origin doesn't specify one, which is why the sync step sets explicit headers on every object.
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