Hugo clean URLs on S3 need a CloudFront Function
Deployed a Hugo site to S3 behind CloudFront. The homepage worked. Every other page returned 403.
The symptom
$ curl -I https://fizz.today/blog/
HTTP/2 403
$ curl -I https://fizz.today/blog/index.html
HTTP/2 200
Hugo generates clean URLs: /blog/my-post/index.html. The link in the nav points to /blog/, not /blog/index.html. S3 doesn’t know what to do with a request for a “directory” — there’s no directory, just a key prefix.
Why DefaultRootObject doesn’t help
CloudFront’s DefaultRootObject only works for the root path (/). It does not apply to subdirectory paths like /blog/. This is a well-documented limitation that catches everyone.
The fix
A CloudFront Function on viewer-request that appends index.html to any URI that ends with /:
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
This handles both cases:
/blog/→/blog/index.html/blog→/blog/index.html(no trailing slash)
Associate it with the default cache behavior as a viewer-request function.
CloudFront Functions vs Lambda@Edge
CloudFront Functions run at the edge, cost nothing at blog scale (free tier covers millions of invocations), and execute in under 1ms. Lambda@Edge would work too but is overkill for URL rewriting.
The gotcha
If you create the function via CLI, don’t try to inline the JavaScript in a bash command. Use a file:
aws cloudfront create-function \
--name fizz-today-url-rewrite \
--function-config Comment="Append index.html",Runtime=cloudfront-js-2.0 \
--function-code fileb://function.js
The fileb:// prefix is important — it sends the file as raw bytes. Inline heredocs and string escaping will mangle your JavaScript in creative ways.