Skip to content

Instantly share code, notes, and snippets.

@bradwestfall
Last active September 22, 2024 17:28
Show Gist options
  • Save bradwestfall/b5b0e450015dbc9b4e56e5f398df48ff to your computer and use it in GitHub Desktop.
Save bradwestfall/b5b0e450015dbc9b4e56e5f398df48ff to your computer and use it in GitHub Desktop.
Use S3 and CloudFront to host Static Single Page Apps (SPAs) with HTTPs and www-redirects. Also covers deployments.

S3 Static Sites

⚠ This post is fairly old. I don't keep it up to date. Be sure to see comments where some people have posted updates

What this will cover

  • Host a static website at S3
  • Redirect www.website.com to website.com
  • Website can be an SPA (requiring all requests to return index.html)
  • Free AWS SSL certs
  • Deployment with CDN invalidation

Resources

S3 Bucket

  • Create an S3 bucket named exactly after the domain name, for example website.com.
  • In Properties, click the Static Website section.
    • Click Use this bucket to host a website and enter index.html into Index Document field.
    • Don't enter anything else in this form.
    • This will create an "endpoint" on the same screen similar to http://website.com.s3-website-us-east-1.amazonaws.com.
  • Then click on Permissions tab, then Bucket Policy. Enter this policy:
{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Sid": "AllowPublicRead",
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::BUCKET_NAME/*"
        }
    ]
}

Be sure to replace BUCKET_NAME with yours.

Note: Naming the bucket doesn't have to be exactly the domain name. I read that in several articles that it needed to be, but it doesn't. If using wildcard domains with AWS, I've read that we can't have dots in the domain name when using wildcard domains. So just know that you can name the bucket whatever, but using dots does work if not using wildcard domains

Uploading an index.html should allow us to visit the "endpoint"

CloudFront

  • Go to the CloudFront section and click Create Distribution and then create for Web, not RTMP.
  • In Origin Domain Name, paste the "endpoint" previously created in S3 (without the http:// part). Note that when you click on this field it will act like a dropdown with options to your existing buckets. I think you can just select one of those two which is a valid list of your S3 buckets.
  • The order of these instructions assume SSL certificates are not setup yet. So don't do anything with settings regarding SSL
  • Select "yes" for Compress Objects Automatically.
  • In Alternate Domain Names (CNAMEs), put the domain names which you want to correspond to this bucket. Put each on their own line OR separated by comma. The reason why you may have two or more is something like this: mywebsite.com and www.mywebsite.com. The field is called "Alternative Domain Names" because AWS will have an aws-specific domain name for the CDN, but you don't want to use that so you'll want to put in your custom domains and then use Route 53 (next section) to point domains to the CDN.
  • In Default Root Object, type index.html.
  • Create. The next screen will show distributions in table form, the one we just made will be "in progress" for a few minutes

The distribution will have a domain name like dpo155j0y52ps.cloudfront.net. This is important for DNS (see below). So copy it somehwere.

Route 53

These DNS instructions assume your DNS is hosted at AWS. This does not mean you have to buy a domain at AWS, it just means that when you buy a domain at somewhere like Google or GoDaddy, over there you need to point NS records to AWS to allow AWS to manage the parts of the DNS record. But first, at AWS is where you create the "Hosted Zone" which is where you create the NS values to eventually give to Google or GoDaddy, etc. I don't know how any of this is different if you buy your domain at AWS (But then again I never buy domains at the same place I host)

  • Click Hosted Zones
  • Create a new Zone: Use the domain name (mywebsite.com without sub domain) for zone. Note that each domain name will get one zone, sub domains all belong to the same zone.
  • This should create NS records such as:
ns-1208.awsdns-23.org. 
ns-2016.awsdns-60.co.uk. 
ns-642.awsdns-16.net. 
ns-243.awsdns-30.com.
  • The NS records can be used to point DNS management from other domain registrar to AWS Route 53
  • Click Create Record Set to create an A record.
    • This will be the record that points mywebsite.com to CloudFront.
    • For the name, enter no value
    • Change Alias to Yes
    • Paste the CloutFront domain in the Alias field
      • This should look like [some-random-number].couldfront.net. You can get this by clicking your CloudFront distribution and in the General tab there is a "Domain Name" label.
    • Click Create Record Set
  • Create another A record for the www redirect
    • Follow the same steps for the previous A record, but enter www for name and use the same CloudFront domain. But note this is because we want www.mywebsite.com and mywebsite.com to point to the same bucket (and therefore the same CloudFront domain). I suppose you would make a whole new bucket and a whole new CloudFront distrubution (with a new CF domain) if you wanted a second project at app.mywebsite.com. This might be common if you app is a React app that is completly separate code from your "home page" website which might be from a static site generator or something.

HTTPS

In the AWS Console, go to Certificate Manager and request a cert for domain and all sub domains. We will be required to verify certificate via email or DNS. If verifying by email, AWS will look up the public DNS owner information and use up to three emails it finds there (if your domain ownership info is public). But even if it's not public, AWS will also use these (that you don't get to choose from)

  • administrator@mywebsite.com
  • hostmaster@mywebsite.com
  • postmaster@mywebsite.com
  • webmaster@mywebsite.com
  • admin@mywebsite.com

If your company uses "webmaster@", hats off to you, because your app is probably 1000 years old.

For .io TLDs: http://docs.aws.amazon.com/acm/latest/userguide/troubleshoot-iodomains.html

If you choose to verify via DNS, AWS will ask you to add some CNAME records to your Route 53 DNS, but the nice thing is that there is a shortcut button to do so (for each domain and sub domain) from within the Certificate Manager section.

After the verification is done and the cert is "issued", we can go back into CloudFont to edit our distribution for this domain:

  • Click the distribution and on the next page (in the General tab), click Edit
  • Check the box for Custom SSL Certificate
  • Select our cert and save. Note that what looks like a text field is really a dropdown menu once you click it to choose your certificate
  • When done with the form, click the Behaviors tab and edit the only record that should be there
  • Select Redirect HTTP to HTTPS. Click Save

SPA

If the website is an SPA, then we need to make sure all requests to the server (S3 in this case) return something even if no file exists. This is becuase SPAs like React (with React Router) need the index.html page for every requests, then things like "not found" pages are handled in the front-end.

Go to CloudFront and click the distribution you want to apply these SPA settings to. Click the Error Pages tab and add a new error page. Fill the form with these fields:

  • HTTP Error Code: 404
  • TTL: 0
  • Custom Error Response: Yes
  • Response Page Path: /index.html
  • HTTP Response Code: 200

Deployment

For deployment, we need to consider that files in the CloudFront CDN are not meant to change. If we were to upload new files to S3, they would not be deployed to the CDN's edge servers and therefore would not update the website. Read More.

To invalidate files on the CDN we'll need to use CloudFront's invalidations feature: Read More.

In the AWS console, in the CloudFront management of a distribution, there is a tab for Invalidations. We could manually create an invalidation (with the value of /*) to invalidate all S3 files. Note that invalidation records here are one-time invalidations and every time we deploy new files, we will need to make a new invalidation.

To deploy with invalidations, we will need to install AWS-CLI first. We also assume you have an IAM user from AWS with an Access Key and Secret Access Key.

To test installation, do:

aws --version

Configure aws-cli:

aws configure --profile PICK_A_PROFILE_NAME

Note that using "profiles" to configure AWS-CLI is probably best since you might want to use the CLI to manage multiple AWS accounts at some point. Be sure to swap out PICK_A_PROFILE_NAME for your name choice (can be anything).

Enter these values:

AWS Access Key ID [None]: [Your Access Key]
AWS Secret Access Key [None]: [Your Secret Access Key]
Default region name [None]: us-east-1
Default output format [None]: json

This will save your entries at ~/.aws/credentials. Note that you need to enter your correct region for your AWS stuff. I used us-east-1, but make sure to use the correct one for you. Also note that you can have responses in text instead of json if you want

You can ommit the last two questions for region and format if you want to set up a default for your computer (that all profiles will use). The default profile is located at ~/.aws/config. If you omit the region and format from your profile, be sure they exist in your ~/.aws/config as:

[default]
output = json
region = us-east-1

Now, since we'll need to do some CloudFront commands which are "experimental", we need to do:

aws configure set preview.cloudfront true

This will result in more records at ~/.aws/config.

We should be setup now to dest a deployment. Run:

aws s3 sync --acl public-read --profile YOUR_PROFILE_NAME --delete build/ s3://BUCKET_NAME
  • Obviously replace YOUR_PROFILE_NAME and BUCKET_NAME with yours. Also this assumes the folder you want to upload is build.
  • This command will
    • Ensure all new files uploaded are public (--acl public-read)
    • Ensure we're using your credentials from your local AWS profile (--profile YOUR_PROFILE_NAME)
    • Remove any existing S3 objects that don't exist locally (--delete)

After deployment is verified and successful, we need to invalidate:

aws cloudfront --profile YOUR_PROFILE_NAME create-invalidation --distribution-id YOUR_DISTRIBUTION_ID --paths '/*'
  • Obviously replace YOUR_PROFILE_NAME and YOUR_DISTRIBUTION_ID with yours. Note that your Distribution ID can be found in the CloudFront seciton of AWS console.
  • If the invalidation worked, you'll be able to see a record of it in the Invalidations tab after clicking on your distribution.

To make it all easier, add to package.json:

  "scripts": {
    "deploy": "aws s3 sync --acl public-read --profile XYZ --delete build/ s3://XYX && npm run invalidate",
    "invalidate": "aws cloudfront --profile XYZ create-invalidation --distribution-id XYZ --paths '/*'"
  },

XYZ is for all the parts that need to be replaced. Now you can run npm run deploy which will deploy then invalidate

Cheers!

@MartinNuc
Copy link

FYI this approach fails when you decide to add security headers (CSP, etc) using Lambda@Edge because CF cannot apply Lambdas to custom error page. Therefore when entering URL which doesn't exist there will be no security headers.

@swaminator
Copy link

AWS launched a service called AWS Amplify Console that provides a workflow for developing and deploying single page apps: http://console.amplify.aws

@jprivillaso
Copy link

Damn, the invalidation key is the key here 👍 Thanks for the info

@nicgirault
Copy link

I automated what you explain in your gist in a script: https://www.npmjs.com/package/aws-spa. Feel free to give it a try!

@pritesh-lahoti
Copy link

@phoenecke, @bogretsovv
Any ideas on how to get around the 404 issue you talked about? Thanks!

@flygis
Copy link

flygis commented Sep 12, 2019

@bradwestfall Thanks for the awesome guide, one thing that isn't clear though. How can I handle 404's in a single page application that has multiple subdomains? Like mysite.com/domain/sub-domain? If I redirect 404's in the sub-domain into /index.html, my page is not loading and it somehow makes all my static files appear as th index.html (app.js and app.css for example). Reloading the page in the /domain works just fine though.

@bradwestfall
Copy link
Author

@flygis Just to clarify, a sub domain is: subdomain.mysite.com, not mysite.com/path/sub-path - I would say what you described is just the "path". Not sure why you're having that issue, but the basic idea is you tell your server to always load index.html for all sub paths. I'm not even sure how you were able to make it only work for /domain/ but not /domain/sub

@flygis
Copy link

flygis commented Sep 18, 2019

@bradwestfall I guess it is basically only looking up one "directory" above the sub-path. I got the thing working though by adding redirection rule into my s3 bucket and custom error handling into cloudfront. Now I am dealing with an issue that my icons are not found with a 301 respoonse. Would you know any redirection rule or similar to work around those responses?

@bradwestfall
Copy link
Author

bradwestfall commented Sep 18, 2019

@flygis I don't remember what I did for that stuff. If you read way above you'll see that I eventually got frustrated with all the mess that AWS makes of this stuff and moved on to Firebase and Google Cloud stuff, which I've been using for two years now and I like it better

@flygis
Copy link

flygis commented Sep 19, 2019

@bradwestfall I have to get this working in AWS since this is not a personal project but a professional one. Anyhow most of the stuff is working already so the icon issue shouldn't be too much of an issue anymore.

@bradwestfall
Copy link
Author

@flygis, sounds good. I don't know but when you get it working please post here because I know others will find it useful

@AustinJuliusKim-Ring
Copy link

@phoenecke wondering if you got any resolution to your multiple origin with api as one custom origin?

@phoenecke
Copy link

@AustinJuliusKim-Ring I don't think I ever found a way to tell CloudFront to only swap a 404 with index.html for S3 and not the API. I think I would just use CORS now.

@AustinJuliusKim-Ring
Copy link

@phoenecke Thanks for the quick reply. It seemed too good to be true using CloudFront to get around CORS, but there's definitely some magic going on and even using a Lambda@edge doesn't provide the right fields to handle proper error configuration and routing.

@pkyeck
Copy link

pkyeck commented Nov 28, 2019

@AustinJuliusKim-Ring @phoenecke you have to use a Lambda@edge function to rewrite the origin-response only for the S3 origin to get the API 4xx responses working. see: https://aws.amazon.com/about-aws/whats-new/2017/12/lambda-at-edge-now-allows-you-to-customize-error-responses-from-your-origin/?nc1=h_ls

@ngetahun
Copy link

I had trouble addind bucket policy for cloudfront and the solution was to create an Origin Access Identity(OAI). https://aws.amazon.com/premiumsupport/knowledge-center/cloudfront-access-to-amazon-s3/

@trentmurray
Copy link

Has anyone had the issue where coming in on the URL CF sends it over to the static S3 transparently and all is good - your domain is preserved and you can view the SPA. However, when I hit Refresh in the browser, it redirects to the S3 underlying aws URL .

Any ideas what might be happening here?

@aohua
Copy link

aohua commented Jan 28, 2020

Hi, I have a few questions hoping you to answer.

  1. During the deployment, will it cause any downtime?
  2. How do you manage rollbacks?

@swnim
Copy link

swnim commented Feb 25, 2020

Hello Brad,
I am Jason from Madup which is an Adtech company in South Korea.
Your post was really helpful information to us.
If you don’t mind, We would like to re-post this post on our company’s Blog with translating.
Our website address is as below; https://tech.madup.com
Please take a look at our site and let us know your opinion about re-posting.
I will wait for your positive response. :)
Thanks.

@bradwestfall
Copy link
Author

Hi @swnim, yea that sounds great. Send me a link when it's up!

@jtomaszewski
Copy link

jtomaszewski commented Feb 29, 2020

I think it's not a zero-downtime deployment though. Let's imagine this case:

  1. Deploy A is done. Some assets are cached on CloudFront.

  2. You're doing a deploy B.

    • During this moment, S3 contains some assets from B, some assets from A, and has missing some assets from A and also some from B.
    • After this moment (but before you invalidate CloudFront cache), CloudFront has some cached assets A, and for other requests it will return assets B.
    • Even after you invalidate the CloudFront cache, the cache isn't invalidated immediately. It might take some time (seconds, minutes?) for it to be invalidated.

So in summary, during the deploy and a moment after it, you might have a situation in which the visitor is hitting index.html from version A, which then tries to load css/js/image files from version A as well, but instead receives assets from version B, or even worse, gets 404 (because assets from version A have been removed from S3).

Correct?

@jtomaszewski
Copy link

To make it zero-downtime (and possibly, make faster rollbacks), we could upload each deploy to a new directory, i.e. /version/${DEPLOY_TIMESTAMP} . So a new deploy doesn't touch the previous deploy's files. (Later on, we could also remove deploys older than X, so we don't keep old deploys for too long on s3.)

Then, instead of invalidating CloudFront cache, we could just update the distribution's origin path. Then, the clients would be served either version A or version B, completely. Both would work in 100%.

I'm wondering, has anybody wrote a CLI script for that maybe?

@swnim
Copy link

swnim commented Mar 13, 2020

Hi @swnim, yea that sounds great. Send me a link when it's up!

Hi @bradwestfall,
Because of your generosity, We finally upload the contents you allowed us. Here is the link we re-posted. https://tech.madup.com/deploy-S3/
It would be very pleasing if you visit our website to see. Thanks again.

@bradwestfall
Copy link
Author

@swnim, I checked it out. Looks great

@kylephughes
Copy link

This was super helpful!

@422158
Copy link

422158 commented Oct 31, 2020

why ttl 0? if I am always serving the same index.html, why not to cache it?
If I set ttl 0 doesn't it mean that each time an url comes that (even the one that was queried 5 seconds ago), cloudfront queries the bucket for the object, eventhough we know it is not there. Makes no sense to me. Correct me if I am wrong please.

@bradwestfall
Copy link
Author

@422158 you're probably right

@cleandevcode
Copy link

Very kind post.
Now I faced an issue with 404 error.
I deployed Angular 9(node.js, express) project to S3 and there is a REST api call on that project.
On my side, there is no routing issue but did not call the API.
Can you please let me know the reason?

@thomasturrell
Copy link

Since this (very useful) gist was written, AWS have added CloudFront functions.

CloudFront functions are similar to Lamda@Edge. But have some advantages when used for URL rewriting. CloudFront functions are available in more locations and have better performance for some use cases.

With CloudFront functions you can rewrite incoming requests, this is ideal for single page applications (SPA). Anyone who is deploying a React, Angular or Vue application that uses a router will probably want to use CloudFront functions instead of CloudFront error pages. CloudFront error pages aren’t well suited to SPA.

@A1bi
Copy link

A1bi commented Feb 7, 2023

Please mention to add the AAAA records in Route 53 as well. Otherwise you will break IPv6 connectivity to your app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment