- date
- entry
- 001
- topic
- infrastructure
- rev
- —
The S3 + CloudFront directory key trap: why /blog/ serves your homepage
What makes this one nasty is that it returns 200. Monitoring stays quiet, curl looks fine, and only a human clicking through finds out the content is wrong.
The common way to put a static site on S3 + CloudFront is a fully private bucket with an
Origin Access Control, so CloudFront fetches through the REST origin. It is secure, cheap,
and what AWS recommends. It comes with one cost:
CloudFront will not resolve /blog/ to blog/index.html for you.
The S3 website endpoint does that. The REST endpoint does not. And the moment you
lock the bucket down with OAC, the REST endpoint is what you are using. So a directory-style
URL like /blog/ comes back as NoSuchKey.
Why it disguises itself as a 200
Most setups add a custom error response mapping 403/404 to /index.html with a
200 status — standard practice for an SPA, since client-side routing depends on it. Put the
two together and you have the trap:
/blog/finds no key on the REST origin- The error response swaps in the homepage and returns 200
A visitor opening /blog/ gets the homepage while the address bar still reads
/blog/. Search engines index the homepage content under that URL. And because
the status code is 200, any health check keyed on status codes stays silent.
A failure that returns 404 gets noticed. A failure that returns 200 can survive for months.
The fix: write index.html to the directory key as well
The most direct approach is to upload every index.html to its "directory key"
during deployment — once with a trailing slash and once without:
cd dist
find . -mindepth 2 -name index.html | while read -r f; do
rel="${f#./}"
dir="${rel%index.html}"
for key in "$dir" "${dir%/}"; do
aws s3api put-object \
--bucket "$BUCKET" --key "$key" --body "$f" \
--content-type "text/html; charset=utf-8" \
--cache-control "public, max-age=0, must-revalidate"
done
done
Both keys matter: blog/ covers the URL people actually type, blog
covers the version without the slash. Skip one and half your links break.
The alternative is a CloudFront Function that appends index.html to paths
ending in / at the viewer request stage. Cleaner, but it is one more thing to
maintain; with a modest number of files the loop above is enough.
The real trap is sync --delete
Once the directory keys exist, a second problem shows up: those keys are
not files in your local dist/. So the next deployment running
aws s3 sync dist/ s3://bucket/ --delete treats them as extraneous objects and
removes every one of them.
Which makes the correct order:
sync --delete(which deletes last run's directory keys)- Write all directory keys again
- CloudFront invalidation
That order has a weak point: if step 2 fails partway, the live site is left with a
batch of 404s. I have had an AWS session expire midway through step 2 — several
commercial pages simply vanished, while everything synced in step 1 was already in place.
The script used set -e, so the invalidation in step 3 never ran either.
Recovery is easy once you know which pages are down. What I do now is a full comparison
after every deploy: pull the <title> out of every
index.html in dist/, request the matching live URL, and compare.
Anything that differs is broken.
This matters more than it sounds, because one check catches two distinct failures: missing directory keys (homepage or 404 served instead), and a deploy that never took effect (old title still served).
If you remember one thing
Status codes are not enough to tell whether a static site is healthy. With an SPA fallback in place, a 200 only means CloudFront had something to hand you. It does not mean it handed you the right thing. Compare the content.
Revision history
- First published