A private S3 bucket behind CloudFront is a common way to host a static site on AWS. The bucket isn't public, and CloudFront reads from it through origin access control (OAC), the method AWS recommends for S3 origins. It's cheap and fast, and nothing can read the bucket except your distribution. I run my own site this way.
But S3 is object storage, not a web server, and three things a web server does for free don't happen:
- Subfolder URLs don't load their
index.html./about/fails. - Missing pages return 403, not 404. A 403 Forbidden means the server understood the request but refuses it; a 404 Not Found means there's nothing at that URL.
- There's nowhere to put a redirect except an HTML page with a meta refresh.
The TL;DR fix: One CloudFront Function for viewer request handles subfolder pages and redirects. One extra statement in the bucket policy, plus one custom error response, turns the 403s into 404s.
The setup
| Piece | Setting |
|---|---|
| S3 bucket | Private, all four Block Public Access settings on |
| CloudFront origin | The bucket's REST endpoint (BUCKET.s3.REGION.amazonaws.com), through OAC |
| Default root object | index.html |
| Viewer protocol policy | Redirect HTTP to HTTPS |
S3's static website hosting would handle subfolder pages and redirects by itself. But the website endpoint needs a public bucket, and CloudFront can only reach it over HTTP. Neither is worth it for a site you care about, so everything below assumes the private-bucket setup.
Gap 1: subfolder URLs don't load their index.html
CloudFront's default root object applies to exactly one URL: /. A request for /about/ goes to S3 as a request for an object named about/. There isn't one; the page is about/index.html. So the request fails, with a 403 (see gap 2 for why it isn't a 404).
The fix is a CloudFront Function on the viewer request event, which runs as soon as CloudFront receives a request and before it checks its cache. It rewrites the path before S3 ever sees it:
/about/becomes/about/index.html./aboutgets a 301 to/about/. Redirecting rather than quietly serving the page matters: a page served at/aboutresolves its relative links from the wrong folder./styles.css, or anything else whose last segment has a file extension, passes through unchanged.
Check only the last segment for an extension. A version that looks for a . anywhere in the path never redirects /v1.2/docs.
Gap 2: missing pages return 403, not 404
Request a page that doesn't exist and you get 403 Forbidden. Google Search Console reports these as "Blocked due to access forbidden (403)".
This is S3 doing its job. If the caller isn't allowed to list the bucket, S3 won't reveal whether an object exists, so every miss looks like a permissions failure. The usual bucket policy only grants CloudFront s3:GetObject.
Google doesn't index either a 403 or a 404, so this isn't a ranking problem. It's a signal problem: a missing page and a real permissions error look identical, to Google and to you.
The fix has two parts.
1. Let CloudFront list the bucket. Here's the full bucket policy. The first statement is the usual read grant; the second is new. s3:ListBucket applies to the bucket itself, so its Resource has no /*. Both statements check the distribution (AWS:SourceArn) and the account (AWS:SourceAccount):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::BUCKET_NAME/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID",
"AWS:SourceAccount": "ACCOUNT_ID"
}
}
},
{
"Sid": "AllowCloudFrontListBucketFor404s",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::BUCKET_NAME",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID",
"AWS:SourceAccount": "ACCOUNT_ID"
}
}
}
]
}
Now S3 can tell CloudFront "not found". The one thing to watch: with list access, a request for the bucket root itself returns a listing of every key. The default root object, and the function below rewriting / to /index.html, mean CloudFront never asks for the root. Keep both in place.
2. Serve a real 404 page. Add a custom error response to the distribution. In the distribution config it looks like this:
"CustomErrorResponses": {
"Quantity": 1,
"Items": [
{
"ErrorCode": 404,
"ResponsePagePath": "/404.html",
"ResponseCode": "404",
"ErrorCachingMinTTL": 60
}
]
}
ResponseCode keeps the 404 status, so visitors see your page and crawlers still see a 404. Mark 404.html itself noindex. ErrorCachingMinTTL is how long CloudFront caches the error; the default is 10 seconds.
Map 404, and only 404. Many guides also map 403, or send every error to index.html. That's right for a single-page app that routes in the browser. For a multi-page site, once CloudFront can list the bucket, a 403 means a real permissions problem, and you want to see it.
Gap 3: redirects as HTML pages are soft 404s
On a server, retiring /about.html is one line of config. On S3, the usual workaround is to leave a tiny HTML page at the old URL with a meta refresh:
<meta http-equiv="refresh" content="0; url=/about/">
Google treats an instant meta refresh like a 301, but it recommends a server-side redirect wherever you can do one. And a near-empty page that returns 200 is the kind of page Search Console reports as a soft 404.
There's no server, but the CloudFront Function can answer before S3 is asked. Add a redirect map to the same function.
It has to be the same function: a cache behavior can run only one function per event type. A second viewer-request function would replace the first, not run alongside it.
The full function
This is the complete function: redirects first, then subfolder pages, then the trailing slash. The map runs before any rewriting, so list every form of an old URL that people or links might use, with and without the trailing slash and with index.html.
// Viewer-request CloudFront Function for a static site in a private S3 bucket.
// Retired URLs, answered with a 301 before anything else runs
var REDIRECTS = {
'/about.html': '/about/',
'/blog': '/posts/',
'/blog/': '/posts/',
'/blog/index.html': '/posts/'
};
function redirect(location) {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: { location: { value: location } }
};
}
function handler(event) {
var request = event.request;
var uri = request.uri;
if (REDIRECTS[uri]) {
return redirect(REDIRECTS[uri]);
}
// /about/ -> /about/index.html (the default root object only covers /)
if (uri.endsWith('/')) {
request.uri += 'index.html';
return request;
}
// /about -> 301 to /about/, only when the last path segment has no file extension
var last = uri.substring(uri.lastIndexOf('/') + 1);
if (last.indexOf('.') === -1) {
return redirect(uri + '/');
}
return request;
}
It's written for the cloudfront-js-1.0 runtime, which is what I've tested it on. The paths in REDIRECTS and the comments are examples. My site runs this same logic, plus one check that sends www to the bare domain.
Test it before it goes live
A CloudFront Function has two stages: DEVELOPMENT, which visitors never reach, and LIVE. The test-function command runs a request through either one, so nothing has to go live untested.
1. Create the function. It starts in DEVELOPMENT. Pass the config as JSON: with the shorthand Comment=...,Runtime=... form, the CLI splits a comment that contains commas and rejects the call.
aws cloudfront create-function \
--name static-site-viewer-request \
--function-config '{"Comment": "Directory index, trailing slash, and redirects", "Runtime": "cloudfront-js-1.0"}' \
--function-code fileb://viewer-request.js
To change an existing function instead, use update-function with the ETag from describe-function. Save the current LIVE code first with get-function --stage LIVE, so you can roll back.
2. Run test events against DEVELOPMENT. Each event is a small JSON file:
{
"version": "1.0",
"context": { "eventType": "viewer-request" },
"viewer": { "ip": "198.51.100.1" },
"request": {
"method": "GET",
"uri": "/about/",
"querystring": {},
"headers": {},
"cookies": {}
}
}
aws cloudfront test-function \
--name static-site-viewer-request \
--if-match ETAG \
--stage DEVELOPMENT \
--event-object fileb://event-about-slash.json
The output shows the request or response the function produced, and how much of its compute budget it used. Run a matrix and compare each result with what you expect:
| Request URI | Expected |
|---|---|
/ |
URI becomes /index.html |
/about/ |
URI becomes /about/index.html |
/about |
301 to /about/ |
/styles.css, /favicon.png, /sitemap.xml |
Unchanged |
/v1.2/docs |
301 to /v1.2/docs/ |
/does-not-exist |
301 to /does-not-exist/, which then 404s |
Every key in REDIRECTS |
301 to its target |
3. Publish and attach it. publish-function copies DEVELOPMENT to LIVE. A function can't be attached until it's published. Then add it to the default cache behavior, and any other behavior that serves HTML, under FunctionAssociations with EventType: viewer-request, and save the config with update-distribution. Once it's attached, publishing a new version is what makes it live.
4. Check the real URLs. Run the same matrix with curl against your domain:
for p in / /about/ /about /styles.css /does-not-exist/ /about.html; do
printf '%-18s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "https://example.com$p")"
done
Expect 200, 200, 301, 200, 404, 301.
Things to know
- Files without an extension get redirected too.
/.well-known/apple-app-site-associationor aLICENSEfile gets a 301 to a folder that doesn't exist. If you serve any, let them through before the trailing-slash check, for exampleif (uri.indexOf('/.well-known/') === 0) { return request; }. - This function drops the query string on its redirects.
/about?ref=xgoes to/about/without?ref=x. The query string is inevent.request.querystring, separate fromuri. If campaign links matter to you, rebuild it into thelocationheader, and add test events with query strings before you publish. - Redirecting old pages to the homepage can still count as a soft 404. Google says so, and it generally ignores the
#fragmentpart of a URL, so/#aboutis the homepage to Google. Redirect to the page that replaced the old one. For a page that's gone, a 404 is the honest answer. - It runs on every request, and it costs almost nothing. CloudFront Functions are $0.10 per million invocations. My site's function ran 58,208 times in the 30 days to November 4, about half a cent.
- Keep the function's source in your repository. A function created in the console has no history and no review. Keep the file next to the site, exclude it from your S3 upload, and deploy it with the CLI or your infrastructure-as-code tool.
The checklist
For a static site in a private S3 bucket behind CloudFront:
- A viewer-request CloudFront Function appends
index.htmlto URIs ending in/, and 301s extensionless paths to a trailing slash. - Redirects for retired URLs are 301s in that same function, not meta-refresh pages.
- The bucket policy grants CloudFront
s3:ListBucketon the bucket, scoped byAWS:SourceArnandAWS:SourceAccount. - A custom error response maps 404, and only 404, to a
noindexpage that keeps the 404 status. - Every function change is tested in DEVELOPMENT with
test-function, then checked live withcurl.
If a coding agent set this up for you, check these three gaps. When I had Claude Code with Agent Toolkit deploy a demo site, it got the private bucket and OAC right. But the toolkit's CloudFront skill, as I read it on November 5, sets the default root object and says nothing about subfolder pages. A one-page demo won't notice. The first site with an /about/ page will.