$ duy_
cd ../blog

03 May 2026 · 2 min read

Serving a static site from S3 without making the bucket public

Origin Access Control, a CloudFront Function for clean URLs, and why a private origin returns 403 instead of 404.

  • aws
  • cloudfront
  • s3
  • terraform

The old way to host a static site on AWS was to turn on S3 static website hosting, attach a "Principal": "*" bucket policy, and point CloudFront at the website endpoint. It works. It also means the bucket is readable by the entire internet, directly, bypassing every header, cache and control you configured at the edge.

This site does it the other way: the bucket has Block Public Access on all four settings, ACLs disabled entirely, and exactly one principal allowed to read it.

Origin Access Control

OAC makes CloudFront sign its origin requests with SigV4. The bucket policy then trusts the CloudFront service, narrowed to one distribution by ARN:

statement {
  sid     = "AllowCloudFrontOAC"
  effect  = "Allow"
  actions = ["s3:GetObject"]

  principals {
    type        = "Service"
    identifiers = ["cloudfront.amazonaws.com"]
  }

  resources = ["${aws_s3_bucket.site.arn}/*"]

  condition {
    test     = "StringEquals"
    variable = "AWS:SourceArn"
    values   = [aws_cloudfront_distribution.site.arn]
  }
}

That SourceArn condition is the part worth being pedantic about. Without it the policy says “any CloudFront distribution may read this bucket” — including one in somebody else’s account.

The 403 that should be a 404

A private bucket is an S3 REST origin, not a website endpoint. Ask a website endpoint for a key that does not exist and it returns 404 NoSuchKey. Ask the REST API and it returns 403 AccessDenied — because s3:ListBucket is not granted, S3 will not confirm or deny that the key exists.

So a missing page arrives at CloudFront as a 403. Both codes have to be mapped:

custom_error_response {
  error_code            = 403
  response_code         = 404
  response_page_path    = "/404.html"
  error_caching_min_ttl = 60
}

Note response_code = 404. Serving the 404 page with a 403 status is the kind of thing that looks fine in a browser and quietly confuses every crawler that visits.

Clean URLs without a server

There is no origin logic to rewrite paths, so /blog/some-post has to become /blog/some-post.html before the cache lookup happens. That is a CloudFront Function on the viewer-request event — sub-millisecond, no cold start, and priced at a fraction of Lambda@Edge:

var lastSegment = uri.substring(uri.lastIndexOf('/') + 1);
if (lastSegment.indexOf('.') === -1) {
  request.uri = uri + '.html';
}

The extension check is what keeps it honest: anything with a dot is an asset and passes through untouched. The same function also 301s www to the apex and strips trailing slashes, so one URL shape reaches the cache instead of three.

Cache headers belong to the upload, not the config

CloudFront’s Managed-CachingOptimized policy respects whatever Cache-Control the origin sends, which means the deploy sets the policy:

# Fingerprinted assets: cache forever.
aws s3 sync dist/ "s3://$BUCKET/" --exclude '*' --include '_astro/*' \
  --cache-control 'public,max-age=31536000,immutable'

# HTML: always revalidate, so a deploy is visible immediately.
aws s3 sync dist/ "s3://$BUCKET/" --exclude '_astro/*' \
  --cache-control 'public,max-age=0,must-revalidate'

Because the HTML is must-revalidate and the assets are fingerprinted, the post-deploy invalidation only needs to cover pages — /* would work but burns free invalidation quota on files that never change under the same name.

What it costs

Route 53 charges $0.50 per hosted zone per month. ACM is free. CloudFront’s perpetual free tier covers 1 TB egress and 10 million requests per month. S3 storage for a few megabytes of HTML rounds to nothing.

So: about fifty cents a month, and the largest line item is DNS.