How to serve multiple React apps in S3 from a single CloudFront distribution
Tags: aws, react, devops, javascript
Published: 2022-02-15 16:30:11

I've been tasked with a very particular problem that I've been racking my brain to solve and despite being a fairly proficient Googler I was really struggling to find a solution. By no means is this a definitive solution, but it is a solution.

This post makes a few assumptions about your knowledge, namely that you already know; how to set up an S3 bucket to host a static website, how to set up a cloud-front distribution to use an S3 static website origin and how to create and link Lambda@Edge scripts to CloudFront distributions.

## The problem

We have a complex React application that has been in use in production for approx 5 years. It has white-label theming and is served on well over a dozen subdomains (one per theme, plus one for our own brand) It is hosted through S3 and CloudFront on AWS. With lot's of alternate names and a wildcard SSL.

For the sake of the post we will say it's hosted on `myapp.example.com` e.g. `myapp.example.com/home` where a white-label would be `yourbrand.example.com/home` resolving to the same content in CloudFront and the same S3 bucket. (Each subdomain is set up in DNS just to CNAME to the same cloudfront URL)

Time has passed and we have now built a new React app, however we are required to host it off of the same subdomain on a static subpath. It is also required that both the old and new apps can keep working in tandem.

e.g. `myapp.example.com/newsite/home`

### Limitations

- You cannot host multiple static sites through a single S3 bucket, the index.html file must be in the root of the bucket.
- CloudFront custom error pages **always** redirect to the default origin regardless of path.

## The solution

Store the new app in a new S3 bucket with static website hosting enabled and use Lamda@Edge function in the CloudFront behaviour on Origin Request to handle requests and if they are on the `/newsite/` path change over to a custom origin that sends traffic to the new site.

Crucially, in this setup, the CloudFront distribution itself does not need to know anything about the new S3 origin directly, the only thing you need to update is the behaviours so that the Origin Request is linked to the Lambda@Edge function. If you have any default error pages you will need to remove them too.

### The app

In the build process of the new app we specify a `PUBLIC_URL` env to put the static content on to a specifc route.

e.g. `/newsite_static/`

### The bucket

The file structure of your new bucket should now resemble something like

```
 - index.html
 - manifest.json
 - newsite_static/
     - static/
       - css/
       - js/
```

### The script

```JavaScript
exports.handler = async (event, context, callback) => {
  const request = event.Records[0].cf.request;
  const newBucketOrigin = "myapp-newsite.example.com.s3-website.eu-west-2.amazonaws.com";
  const MATCHING_PATHS = ['/newsite/', '/newsite_static/'];

  /**
   * If we want to use the new app, based on path,
   * Then set custom origin for the request
   * to override cloudfront config
   */
  if (MATCHING_PATHS.some(path => request.uri.startsWith(path))) {
    request.origin = {
      custom: {
        domainName: newBucketOrigin,
        port: 80,
        protocol: "http",
        path: "",
        sslProtocols: ["TLSv1", "TLSv1.1", "TLSv1.2"],
        readTimeout: 5,
        keepaliveTimeout: 5,
        customHeaders: { ...request.origin.custom.customHeaders }
      }
    }
    request.headers['host'] = [{ key: 'host', value: newBucketOrigin }];
  }
  callback(null, request);
};

```

Our script is slightly modified from the above, as we use the same script across multiple cloudfront distros so the origin match is not a hard coded string but an object which has a lookup performed against the host to find the correct origin.

## Outcome

Requests to myapp.example.com/home still go to the legacy app
Request to myapp.example.com/newsite/home now go to the new app.

## Enhancements

Because we've turned off the custom error pages in CloudFront that handle things falling back to our index.html file we have also introduced a 2nd Lambda@Edge script to fire on **Origin Response** to handle that same status code change.

## Special mentions

I spent **a lot** of time trying to figure this out so it worked exactly as we needed it to, and read a lot of articles and watched a lot of content, some more helpful than others. 2 pieces in particular really helped though. 
- [Lambda@Edge gotchas and tips](https://medium.com/@mnylen/lambda-edge-gotchas-and-tips-93083f8b4152)
- [Serving Multiple Websites with AWS CloudFront & Lambda at Edge](https://www.youtube.com/watch?v=Cz0unTbOfgo)