TABLE OF CONTENTS

Serve index.html For Your Static Website Subdirectories Using S3, CloudFront and Lambda@Edge

Author Sagi Liba
Sagi Liba on Nov 23, 2022
8 min 🕐

Lately, I've decided to build this blog from scratch using a static site generator called Gatsby, I've also gotten into learning more about AWS and its services, so naturally I've decided to use AWS to host my blog.

While uploading the blog to S3 and connecting it to CloudFront I've encountered a strange behavior, accessing subdirectories does not return its index.html file automatically, causing a 403 error.

Assumptions

Here are a few assumptions about the state of your S3, CloudFront:

  • You have an S3 bucket served with static website hosting.
  • CloudFront distribution connected to the S3 bucket.
  • Accessing your site through S3 directly is disabled, you can only access it through CloudFront.

Problem

I've created a new S3 bucket and connected it to a new CloudFront distribution to recreate the problem. I have 2 files:

  • index.html, at the root.
  • posts directory, with index.html inside.

When you enter the root of the application, in this case:

Copy
https://dhtc9h3sfm5xl.cloudfront.net/

Its index.html file will be served as expected, but when you access a subdirectory route (/posts) you will receive a 403 error (Access Denied), or you will be going back to the root page in case you've set automatic redirects in case of an error.

In case your application is a single page application, you might be able to navigate from the homepage to different locations that include a subdirectory, but when you refresh that page, it will also return a 403 error / redirect to homepage.

access-denied

Copy
https://dhtc9h3sfm5xl.cloudfront.net/posts/

Accessing the /posts/ will not return its index.html file as expected.

Usually web servers serve index.html by a default when a subdirectory is asked.

It took me a while to understand what was going on, and even to understand how to Google it. AWS has plenty of services with their own lingo for different cases.

When you create an S3 bucket and set it as a static website you receive a URL that you can use to access the website such as:

Copy
<bucket-name>.s3-website-<AWS-region>.amazonaws.com

While using this URL to view your website you will find that it is working correctly, and will return an index.html file for your subdirectories correctly. But when connecting CloudFront you will find that CloudFront is only able to serve a default root object (index.html) for the root folder, and not for its subdirectories.

It happens because CloudFront is doing an S3 GetObject API call against a key that does not exist in your S3 Bucket. The subdirectory does exist, but it does not resolve to an S3 object. Keep in mind that S3 is an object store, so there are no real directories. User interfaces such as the S3 console present a hierarchical view of a bucket with folders based on the presence of forward slashes, but behind the scenes the bucket is just a collection of keys that represent stored objects.

Solution

To serve index.html you will have to create a new lambda function usingLambda@Edgethat will replace any URL ending with "/" with "/index.html". This is the equivalent to what S3 does on its own with static website hosting.

Let's create a lambda function and serve it using Lambda@Edge, the service is not availble for all regions, currently I've used us-east-1 (N. Virgina) as it has Lambda@Edge enabled.

  • Go to the AWS Console, enter the Lambda service.
  • choose "Create function".
  • choose "Author from scratch", as you’ll use the sample code I've provided.
  • Name your function, I've named mine: IndexRouteRewriteLambdaEdgeExample
  • Select your runtime, I've picked Node.js 14.x ( you might have a higher version of Node.js when you see this article).
  • Finish by clicking on "Create Function".

Now use the following code to replace any URL ending with "/" to "/index.html".

Copy
"use strict";
exports.handler = (event, context, callback) => {
// Extract the request from the CloudFront event that is sent to Lambda@Edge
var request = event.Records[0].cf.request;
// Extract the URI from the request
var olduri = request.uri;
// Match any '/' that occurs at the end of a URI. Replace it with a default index
var newuri = olduri.replace(/\/$/, "/index.html");
// Replace the received URI with the URI that includes the index page
request.uri = newuri;
// Return to CloudFront
return callback(null, request);
};

Next, configure the trigger.

lambda-trigger

  • Click on "Add Trigger".
  • Select the CloudFront trigger (It will not show up for unsupported regions).

select-cloudfront-trigger

  • Click on "Deploy to Lambda@Edge".

deploy-to-lambdaedge

  • Select your distribution, make sure its correct.
  • Check "Confirm deploy to Lambda@Edge".
  • CloudFront Event should be "Origin request", as this is the correct time to change the request url.
  • Click "Deploy".

deploy-to-lambda-edge

You might encounter the following error:

Your functions execution role must be assumable by edgelambda.amazonaws.com service principal

If the error has not popped up then continue.

To handle this error, we must set a Trust Relationship for our lambda role, telling that it can trust edgelambda.amazonaws.com.

  • Duplicate your current Tab.
  • Go to IAM.
  • Roles, find & click on your lambda.
  • Select "Trust relationships" & "Edit trust relationship".

iam-trust-relationship

You will find a policy similar to this one:

Copy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}

You need to add edgelambda.amazonaws.com to the principal service:

Copy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": ["edgelambda.amazonaws.com", "lambda.amazonaws.com"]
},
"Action": "sts:AssumeRole"
}
]
}
  • Click "Update Trust Policy"
  • Go back to your trigger tab, and click "deploy" again ( if the error shows up again, be patient, it takes a minute or two to update the trust relationship ).

Finally you have connected the CloudFront trigger and served it through Labmda@Edge.

Now go to your Cloudfront distribution URL and enter any of your subdirectory that you know has an index.html file.

You might encounter a caching issue, if you have not set a cache invalidation (when to invalidate the cache), meaning it will still have the issue happening.

Open a new private window / invalidate the cache and you will see that it works as expected. The lambda should now serve your index.html file correctly while the URL will keep its "/" at the end.

© 2020-present Sagi Liba. All Rights Reserved