Serving a static site from S3 without tripping over clean URLs
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.