Baobaobaolin.com
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:

  1. /blog/ finds no key on the REST origin
  2. 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:

  1. sync --delete (which deletes last run's directory keys)
  2. Write all directory keys again
  3. 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

  1. First published