Production deploy of a Single Page App using S3, CloudFront, and CloudFormation
So you’re building a Single Page Application or SPA (i.e. a website that doesn’t reload the page as the user interacts) that talks to a backend API service over a HTTP REST or GraphQL API, and you’re getting ready to deploy it. The frontend is static (maybe it’s React or Vue.js) — it should be easy to deploy, right? Over the past year, I’ve been involved in deploying several webapps like these, and there are definitely some gotchas.
In this post, I’ll walk through implementing a number of hard-earned through trial and error best practices for hosting a SPA using AWS S3 and CloudFront. These include:
- Building and uploading content to AWS S3 with the correct HTTP Cache headers so that code for a deploy is immediately picked up by clients.
- Configuring Continuous Deployment via CircleCI.
- Serving your website over https
- Supporting both
www
and the non-www
(bare) variant of your domain. - Supporting deep links into the website using proper URLs (e.g.
https://mywebsite.com/product/1
) rather than using hash-based routing (e.g.https://mywebsite.com/#/product/1
). - Supporting configuration of environment-specific settings (e.g. API endpoints) with the same build artifacts.
- Configuring security of your API via CORS and secure cookies.
Building and Deploying the SPA
Most SPAs use NPM or Yarn as a build system, so a command like npm run build
results in a set of artifacts:
$ find build
build
build/favicon.ico
build/index.html
build/static
build/static/css
build/static/css/main.8fe37469.css.map
build/static/css/main.8fe37469.css
build/static/js
build/static/js/main.7d900448.js.map
build/static/js/main.7d900448.js
As you can see in the build artifacts (which are generated via create-react-app), there are CSS and JavaScript files that have an embedded hash in the filename. These static files are meant to be cached indefinitely in the browser — when you generate a new build, the new build artifacts will have different hashes in their file names.
Meanwhile, index.html
references these static files. Looking inside of that file:
<link href="/static/css/main.8fe37469.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script type="text/javascript" src="/static/js/main.7d900448.js"></script></body></html>
A new build will generate a new index.html
with updated CSS and JavaScript references. Thus, the browser (and CDN) should not cache index.html
in order to reliably release new versions of your application via new JavaScript/CSS files.
Creating an S3 bucket
In the next few sections, I’m going to include snippets of CloudFormation YAML to demonstrate how to setup your AWS resources.
To kick us off, let’s generate the S3 bucket, which will be configured to serve static content over HTTP. The following CloudFormation includes minimal configuration for:
- Creating a bucket in S3, configured for public access and with a website configuration.
- Generating a bucket policy that allows everyone (
*
) to read the files in the bucket. - Configuring via the
BucketName
a parameter because we want to reuse this configuration for multiple environments (e.g. adev
and aprod
environment).
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
BucketName:
Type: String
Resources:
S3:
Type: "AWS::S3::Bucket"
Properties:
AccessControl: PublicRead
BucketName: !Ref BucketName
WebsiteConfiguration:
IndexDocument: index.html
S3BucketPolicy:
Type: "AWS::S3::BucketPolicy"
Properties:
Bucket: !Ref BucketName
PolicyDocument:
Statement:
-
Action: [ "s3:GetObject" ]
Effect: Allow
Principal: "*"
Resource: !Sub "arn:aws:s3:::${BucketName}/*"
Reference: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3-bucket.html
After applying this CloudFormation configuration, you now have a bucket to upload your static content.
Uploading static content with correct caching headers
We will do the upload in two parts, using the aws s3 sync
command:
- we sync the build directory except for the
index.html
file and set theCache-Control: max-age=604800
(1 week — but we could do longer) - Next, we sync the remaining files (just
index.html
) withCache-Control: no-cache
. If you want to set the no-cache header on additional files, add them to the exclude of the first command.
aws s3 sync --cache-control 'max-age=604800' --exclude index.html build/ s3://mywebsitebucket/
aws s3 sync --cache-control 'no-cache' build/ s3://mywebsitebucket/
CircleCI Configuration
Rather than doing the upload to S3 manually, let’s automate it so that it runs continuously whenever there is a merge to master. It’s easy to do with CircleCI (and other tools, too!). There are three steps needed to implement this:
- Create an IAM User (and IAM access/secret keys) with permissions to upload to the bucket.
- Setup the IAM access/secret keys in CircleCI Web UI.
- Add a
deploy
job to the CircleCI configuration.
For the first step, we follow the best practice of creating an IAM Group with the IAM policy containing S3 upload permissions. From there, we create an IAM user within that group. Here’s the CloudFormation:
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
UploadBucket:
Type: String
Resources:
S3WebsiteUploadGroup:
Type: "AWS::IAM::Group"
Properties:
GroupName: "S3WebsiteUpload"
Policies:
- PolicyName: write-to-s3
PolicyDocument: !Sub |
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:Get*",
"s3:List*",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::${UploadBucket}",
"arn:aws:s3:::${UploadBucket}/*"
]
}
]
}
CircleCIUploadUser:
Type: "AWS::IAM::User"
Properties:
Groups:
- !Ref S3WebsiteUploadGroup
UserName: CircleCIUploadUser
While the above creates the User and Group, it doesn’t create the Access and Secret Access keys. I create those with the AWS CLI because CloudFormation doesn’t have a good solution to generating Access Keys (you can store the keys as output, but then anyone with read access to CloudFormation can see those values):
$ aws iam create-access-key --user-name CircleCIUploadUser
Next, configure the credentials that you just generated in CircleCI: https://circleci.com/docs/2.0/deployment-integrations/#aws
Finally, add a deploy
step to .circleci/config.yml
. Include it after the build
step (make sure to include the build
directory in the persist_to_workspace
of your build
job).
deploy:
docker:
- image: circleci/python:2.7-jessie
working_directory: ~/
steps:
- attach_workspace:
at: workspace
- run:
name: Install awscli
command: sudo pip install awscli
- run:
name: Deploy to S3
command: |
cd workspace
aws s3 sync --cache-control 'max-age=604800' --exclude index.html build/ s3://mywebsitebucket/
aws s3 sync --cache-control 'no-cache' build/ s3://mywebsitebucket/workflows:
version: 2
build-deploy:
jobs:
- build
- deploy:
requires:
- build
filters:
branches:
only: master
Serving content via https, supporting deep links, and redirecting from www
While S3 is great for serving a static website, it doesn’t support serving content on a custom domain over https. In this section we’ll cover how to:
- Redirect
www.mywebsite.com
tomywebsite.com
- Host our website at
https://mywebsite.com
(using AWS certificates) - Support deep links into the React App (e.g. using React Router) so that URLs other than
/
or/index.html
load correctly.
To do all of this, we’ll use CloudFront (AWS’s Content Delivery Network), Route53 for DNS, and an additional S3 bucket to service the redirects. We’ll also configure a certificate using the AWS Certificate Manager.
First, we’ll create the secondary S3 bucket to redirect www.mywebsite.com
to mywebsite.com
. This bucket has a website configuration such that all URLs are redirected by updating the hostname to RedirectTarget
(which is the public DNS entry of our website). With this config, deep links to www.myswebsite.com
keep the portion of the URL after the domain name during the redirect ( i.e. www.mywebsite.com/some-deep-link
will redirect to mywebsite.com/some-deep-link
.
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
BucketName:
Type: String
RedirectTarget:
Type: String
Resources:
S3RedirectBucket:
Type: "AWS::S3::Bucket"
Properties:
AccessControl: PublicRead
BucketName: !Sub "www.${BucketName}"
WebsiteConfiguration:
IndexDocument: index.html
RoutingRules:
- RedirectRule:
HostName: !Ref RedirectTarget
Second, we create two CloudFront distributions — one with the main bucket as the origin and the second with the redirect bucket as the origin. A couple of notes on the following template:
- We use the same certificate for both domains. Ensure that your certificate is setup as a wildcard or for both via subjectAltName (see below for an example).
- We access the S3 origin via HTTP, not via the builtin CloudFront->S3 integration. This is required to leverage the headers (such as redirects) from S3.
- To support deep links, the main Distribution has a
CustomErrorResponses
setting that redirects all 404s toindex.html
and returns a 200. This means that your website will never 404 — If the URL is invalid (or requires login, etc) then the SPA will have to communicate that to the user.
# See: https://www.superloopy.io/articles/2017/route-53-cloudformation.html
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
DomainName:
Type: String
OriginDomainName:
Type: String
OriginRedirectDomainName:
Type: String
AcmCertificateArn:
Type: String
Resources:
Distribution:
Type: "AWS::CloudFront::Distribution"
Properties:
DistributionConfig:
Aliases:
- !Ref DomainName
# S3 website is our origin, so we can have redirects
Origins:
- Id: !Sub "S3-${DomainName}"
DomainName: !Ref OriginDomainName
CustomOriginConfig:
OriginProtocolPolicy: http-only
# Don't do any forwarding, send all through to the S3 origin.
CustomErrorResponses:
- ErrorCode: 404
ResponseCode: 200
ResponsePagePath: /index.html
DefaultCacheBehavior:
ForwardedValues:
Cookies:
Forward: none
Headers: []
QueryString: "false"
TargetOriginId: !Sub "S3-${DomainName}"
ViewerProtocolPolicy: redirect-to-https
DefaultRootObject: index.html
Enabled: "true"
HttpVersion: http2
PriceClass: PriceClass_100 # US, Canada, and Europe
ViewerCertificate:
AcmCertificateArn: !Ref AcmCertificateArn
SslSupportMethod: sni-only
Tags:
- Key: Name
Value: !Ref DomainName
RedirectDistribution:
Type: "AWS::CloudFront::Distribution"
Properties:
DistributionConfig:
Aliases:
- !Sub "www.${DomainName}"
# S3 website is our origin, so we can have redirects
Origins:
- Id: !Sub "S3-${DomainName}"
DomainName: !Ref OriginRedirectDomainName
CustomOriginConfig:
OriginProtocolPolicy: http-only
# Don't do any forwarding, send all through to the S3 origin.
DefaultCacheBehavior:
ForwardedValues:
Cookies:
Forward: none
Headers: []
QueryString: "false"
TargetOriginId: !Sub "S3-${DomainName}"
ViewerProtocolPolicy: redirect-to-https
DefaultRootObject: index.html
Enabled: "true"
HttpVersion: http2
PriceClass: PriceClass_100 # US, Canada, and Europe
ViewerCertificate:
AcmCertificateArn: !Ref AcmCertificateArn
SslSupportMethod: sni-only
Tags:
- Key: Name
Value: !Ref OriginRedirectDomainName
References:
- https://johnlouros.com/blog/using-CloudFront-to-serve-an-SPA-from-S3
- https://www.brautaset.org/articles/2017/route-53-cloudformation.html
Creating a Certificate
If you want to create your certificate using CertificateManager (using email verification), here’s an example snippet. The certificate can be used for both the main (bare) domain name as well as the www
variant (see the SubjectAlternativeNames
entry).
# See: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-certificatemanager-certificate.html
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
DomainName:
Type: String
Resources:
Certificate:
Type: "AWS::CertificateManager::Certificate"
Properties:
DomainName: !Ref DomainName
SubjectAlternativeNames:
- !Sub "www.${DomainName}"
Outputs:
CertificateARN:
Value: !Ref Certificate
Build and runtime configurations
Create React App and similar tools typically allow you to specify build-time variables. Often times, though, you want to specify a runtime configuration. Examples include API keys or API endpoints that might differ based on environment. In that case, you might want a runtime configuration that is rendered at page load time. To do so, add a snippet to your index.html to load that that file:
<script src="/config.js" type="text/javascript"></script>
Create a config.js
file. Make sure you add this file to the --exclude
list when uploading to S3, so that it is not cached.
# config.js
window.__runtime_configuration = {
"apiKey": "...",
"apiEndpoint": "http://api-prod.mywebsite.com/"
}
Now, you can refer to window.__runtime_configuration
to reference these values. You can safely put this file in the HEAD
of index.html
: a modern browser will fetch it and the main JavaScript bundle (which will be much larger) in parallel.
API server configuration
Modern browsers implement Cross-Origin Resource Sharing (CORS) protections. In this type of setup, your API server is on a different domain than your static assets, so you need to implement a CORS handler to whitelist your domain name. It’s typical in development to whitelist for all domains, but to restrict to just your exact domain in production. In an Express application, the code to do this looks like the following:
// NOTE: These two should be loaded via a config file
var whitelist = ['http://example1.com', 'http://example2.com']
var whitelistAll = true var corsOptions = {
origin: function (origin, callback) {
if (whitelistAll || whitelist.indexOf(origin) !== -1) {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS'))
}
}
}
app.use(cors(corsOptions))
For securing cookies, the Express website has a good set of best practices: https://expressjs.com/en/advanced/best-practice-security.html#use-cookies-securely
Conclusion
This tutorial describes how to build and upload a static website to Amazon S3, serve the website using Amazon CloudFront using best practices like redirect-to-HTTPS and deep links via React Router, use runtime configuration to talk to APIs, and enable CORS for your API.
While each of these items own its own isn’t particularly difficult, it can be a struggle to figure out how to fit them all together. While AWS has all the building blocks, it takes a lot to string them all together.
There are a couple of tools and services out there that provide a simpler, batteries-included version of this workflow. You may want to check them out instead. In particular, I’ve used Zeit’s Now service and have heard good things about Netlify.