Using AWS CloudFront Functions with a Static Site in Terraform

I recently updated the web sites I’ve been hosting in AWS to finally stop using the “Static website hosting” config. I was also using CloudFront, but letting S3 manage the URL routing. It’s been many years since I first configured an S3 static website, and my Terraform config needed refreshing. Many small changes and additions added up to a lot of work! I factored out most of the resources into a module.

This biggest benefit of plain S3 static website hosting, other than simplicity, is the routing of URLs follows the S3 object prefixes, so most URLs “just work”. The biggest drawback is the lack of HTTPS support (and therefore no HTTP/2 or /3). The bucket itself must also be public. Also, if you want the naked domain example.com redirected to www.example.com or vice versa (usually best practice) then you need an entire separate bucket with static website hosting enabled, but with the Hosting type changed to redirect to the first bucket.

In addition to HTTPS support with CloudFront, CloudFront Functions can solve the routing problem. This was possible first with Lambda@Edge functions, but CloudFront Functions, introduced in May 2021, have made this simpler and cheaper. Here’s my function that handles redirects and routing (also here):

async function handler(event) {
    const request = event.request;
    const uri = request.uri;

    // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/example-function-add-index.html
    // Check whether the URI is missing a file name.
    if (uri.endsWith("/")) {
        request.uri += "index.html";
    }
    // Check whether the URI is missing a file extension.
    else if (!uri.includes(".")) {
        request.uri += "/index.html";
    }

    if (request.headers.host) {
        const host = request.headers.host.value;
        if (!host.startsWith("www")) {

            // prevent redirect weirdness
            if (request.uri.endsWith("index.html")) {
                request.uri = request.uri.replace(/index\.html+$/, "");
            }

            return {
                statusCode: 301,
                statusDescription: "Moved Permanently",
                headers: {
                    "location": {"value": `https://www.${host}${request.uri}`}
                }
            };
        }
    }

    return event.request;
}

Now I could delete those redirect buckets and lock down the buckets to only be accessible to CloudFront via OAC. In my case, I spent a lot of time importing resources around in Terraform state files to clean up existing resources, but this is the gist of it.