Ryan Brooks

Serving a static site from S3 without tripping over clean URLs

· aws, cloudfront, terraform

Astro’s default build format emits posts/my-post/index.html. Every static host you’ve used before resolved that automatically, so it’s easy to assume S3 will too.

It won’t. An S3 REST origin serves objects by exact key. A request for /posts/my-post/ asks for an object whose key is literally posts/my-post/, which doesn’t exist, and with Origin Access Control granting only s3:GetObject, the bucket answers 403 rather than 404 — S3 only discloses that a key is missing to callers holding ListBucket.

The fix, and the way it usually goes wrong

A CloudFront Function on viewer-request rewrites the path before it reaches the origin. The naive version looks like this:

if (!request.uri.endsWith('/')) {
  request.uri += '/index.html';
}

That breaks the entire site. /rss.xml becomes /rss.xml/index.html. Every hashed asset under _astro/ becomes a 403. The check has to be for a file extension on the final path segment, not a trailing slash:

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

Testing the last segment rather than the whole URI matters more than it looks — a slug like /posts/node.js-notes/ contains a dot, and a naive uri.indexOf('.') sends it straight to the origin unrewritten.

Redirect, don’t just rewrite

The remaining case is an extensionless path with no trailing slash: /posts/my-post. You can rewrite it to the same place, but then both forms serve a 200 and every page has two URLs. Issue a 301 to the trailing-slash form instead, and set trailingSlash: 'always' in astro.config.mjs so internal links point at the canonical form directly rather than eating a redirect hop.

One function, three branches, and the whole class of “why is my static site 403ing” disappears.