fizz.today

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:

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.

#aws #cloudfront #hugo #s3