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