Thomas Willingham

Thomas Willingham

© 2023

Multisite Deployment with AWS CloudFront

AWS CloudFront is a popular solution for deploying websites and single page applications (SPAs). There are a number of useful resources online explaining how to accomplish this. When developing SPAs, it’s often useful to be able to quickly deploy different branches or versions of the code, but in the case of hosting with CloudFront, this typically means overwriting another version or setting up additional infrastructure. This post explains how to deploy multiple sites on different subdomains using a single S3 bucket and CloudFront distribution. The reasoning behind this is to be able to push different SPAs or SPA versions to the same S3 bucket, giving each their own domain, without having to provision any additional infrastructure. This is not a step-by-step tutorial, but it does contain relevant sample code.

CloudFront Overview for SPAs

Hosting a website in CloudFront is relatively straightforward. Create an S3 bucket and create a CloudFront distribution that uses the S3 bucket as its origin. As with most SPAs, a single index file is served for each url that is requested. (e.g. example.com and example.com/about both return the same index.html document) CloudFront provides an easy setting for this. Just set the default index document to index.html (or whatever) as you would with any site, and set the error document to the same value.

This is due to the way CloudFront serves files. When a path, such as /login or /user/account is requested, CloudFront will look for an object matching that path in the S3 origin bucket. Since such a path does not exist, CloudFront would return a key error to the user, when the desired behaviour is to let the SPA handle this path. Setting the error document to the index document, will give the desired behaviour.

This is a well-documented method for hosting SPAs on CloudFront.

Here’s the gotcha: CloudFront will not return index or error documents in ‘subdirectories’.

Related: S3 is an object store and doesn’t actually have directories.

Routing and S3 bucket structure

Since the goal is to use different subdomains as the differentiator between sites, the S3 bucket will contain a separate folder for each site. e.g. The following domains should point to the following ‘directories’. site1.example.com => s3:///site1 site2.example.com => s3:///site2 site3.example.com => s3:///site3

Furthermore, a wildcard DNS record will need to be created direct all traffic from *.example.com to the CloudFront distribution.

Modifying the request with CloudFront and Lambda

CloudFront provides four different types of hooks to modify requests and responses via Lambda@Edge functions, but we only need to focus on two, the Viewer Request and the Origin Request. In AWS CloudFront console, under Behaviours, Lambda function ARNs can be specified for each type of hook.

Viewer Request

This is triggered as soon as the request from a client comes to CloudFront. In this request, we need to take the leftmost part of the domain and add it as a prefix to the URI.

function.js

exports.handler = (event, context, callback) => {
  // Get request object from CloudFront event
  var request = event.Records[0].cf.request;

  // Extract host from request
  // e.g. host = site1.example.com
  const host = request.headers.host[0];

  // Extract folder from host
  // e.g. folder = site1
  const folder = host.value.split(".")[0];

  // update uri to include folder
  request.uri = '/' + folder + request.uri;

  return callback(null, request);
};

Origin Request

This is triggered after the request has entered CloudFront, but before the request hits the S3 origin bucket. In this request, we need to take the return a file for any file that is specified, otherwise, return the index document in the subdomain folder.

function.js


function isValidFile(string) {
  const validExtensions = [
    // List extensions of any filetypes that may be used.
    '.html',
    '.js',
    '.css'
  ]
  for (var i in extensions) {
    if (old_uri.endsWith(extensions[i])) {
        return True;
    }
  }
  return False;
}
exports.handler = (event, context, callback) => {
    // Set this variable to whatever the primary file should be in a folder
    DEFAULT_INDEX_DOCUMENT = 'index.html'

    var request = event.Records[0].cf.request;
    var requested_uri = request.uri;
    var new_uri;
    if (isValidFile(requested_uri)) {
      new_uri = requested_uri
    } else {
      new_uri = '/' + old_uri.split('/')[1] + '/' + DEFAULT_INDEX_DOCUMENT;
    }

    // Replace the requested uri with the new one
    request.uri = new_uri;

    // Return to CloudFront
    return callback(null, request);
};

Once the DNS record, viewer request, and origin request are setup, you can push code to a ‘subfolder’ in the S3 bucket, and immediately access it from subfolder.<your base url>.

Limitations

This solution is not perfect, and there are absolutely limitations to using it. Using the above code as an example, here are some outcomes that will happen that might have otherwise not been expected.

Requested URL URI Sent to Origin Bucket User Experience
site1.example.com/cat.png example.com/site1/index.html
Because .png is not in listed as a valid file
Depending on the SPA’s handling of invalid paths, the user should see a 404 generated by the SPA.
site1.example.com/page.html example.com/site1/page.html
Because .html is listed as a valid file
Since the page.html won’t exist in the /site/ directory, CloudFront will return the top-level index.html of the bucket, or a KeyError, if it doesn’t exist. This could be a particularly bad user experience.

Conclusion

There may be a better way to validate if something is a file than the above code, so if you know of one, please let me know. For now, it is likely that you will need to modify the isValidFile function in the origin request, to suit your specific case.

Having used this method a number of times now, I can say that it does work pretty well for internal development purposes, as it allow developers to quickly have their code running at a unique URL.