skip.link.title

How to Deploy React App (any static website) to S3 and CloudFront?

  • You can find the source code for this video in my GitHub Repo.

Intro

In this tutorial, we will create React App from scratch and declare a few routes using React Router. First, we will deploy React app to S3 using AWS Console and enable website hosting. We will also set up a custom domain to point to the S3 bucket. This approach won't allow us to secure our website with HTTPS. To use TLS, we would need to deploy our app to CloudFront, which is a content delivery network operated by Amazon Web Services. In addition, to the standard features of CDN, it can automatically compress files from the S3 using gzip and Brotli to improve performance and reduce the cost of the hosting in general. If you host your domain outside of AWS, you will only be able to set up a custom subdomain. For example, instead of devopsbyexample.io, you can only use www.devopsbyexample.io. If you want to use the root domain, I'll show you how to transfer your domain to the AWS Route53 and set up an alias for CloudFront distribution. Finally, we will create CI/CD pipeline using GitHub Actions to test and deploy our app to S3 and CloudFront. We will create a dedicated IAM user with permissions to upload files and invalidate cache in CloudFront.

Create React App

We will quickly create a simple react app for this tutorial by using a create-react-app tool. In addition, we will import React Router and define a couple of routes. I discovered when you deploy complex apps to hostings such as GitHub Pages, Firebase, and others, you need to make sure that all the pages work as expected. Sometimes you may face issues with redirects. React Router will help us validate primary use cases in the CloudFront environment. Since we will create a CI/CD pipeline at the end of the video, we would need to take care of installing dependencies as well.

Before you start, you need to have NodeJS installed on your computer. You can follow this instruction to install it. New NodeJS versions come with the npx package manager that helps you to install dependencies and run commands even if you haven't installed that module yet. Let's generate React app.

npx create-react-app www.devopsbyexample.io

To run it, you need to switch to the React folder and run the npm start command. It should open a browser with the default page.

cd www.devopsbyexample.io
npm start

Next, we need to install React Router.

npm install react-router-dom@6

We need to create two routes for our application. Let's call them expenses and invoices and place them under src/routes/ folder. This example is straight from the React Router tutorial.

src/routes/expenses.js
1
2
3
4
5
6
7
export default function Expenses() {
    return (
        <main style={{ padding: "1rem 0" }}>
            <h2>Expenses</h2>
        </main>
    );
}
src/routes/invoices.js
1
2
3
4
5
6
7
export default function Invoices() {
    return (
        <main style={{ padding: "1rem 0" }}>
            <h2>Invoices v3</h2>
        </main>
    );
}

Now, let's update App.js to include links.

src/App.js
import './App.css';
import { Link } from "react-router-dom";

function App() {
  return (
    <div>
      <h1>Bookkeeper</h1>
      <nav
        style={{
          borderBottom: "solid 1px",
          paddingBottom: "1rem",
        }}
      >
        <Link to="/invoices">Invoices</Link> |{" "}
        <Link to="/expenses">Expenses</Link>
      </nav>
    </div>
  );
}

export default App;

Also, we need to update index.js to wrap the content in BrowserRouter.

src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {
  BrowserRouter,
  Routes,
  Route,
} from "react-router-dom";
import Expenses from "./routes/expenses";
import Invoices from "./routes/invoices";

ReactDOM.render(
  <React.StrictMode>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<App />} />
        <Route path="expenses" element={<Expenses />} />
        <Route path="invoices" element={<Invoices />} />
      </Routes>
    </BrowserRouter>,
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

Deploy React App to AWS S3 Bucket

When you configure your bucket as a static website, the website is available at the AWS Region-specific website endpoint of the bucket. These URLs return the default index document that you configure for the website. You can also set up a custom domain.

First of all, let's create an S3 bucket. When you choose a name for the bucket, you want to match it with a custom domain that you want to use. In my case, it's www.devopsbyexample.io. Right now, I use google domains to host my domain; that's why I can only use the subdomain www. In case I would want root domain devopsbyexample.io, I would need to transfer it to Route53; we are going to do it later.

  • Give it the name www.devopsbyexample.io and choose the region.
  • To host the website in the S3 bucket, you also must disable Block all public access. On the other hand, you can keep it on if you want to host it only via CloudFront. We will discuss differences later.
  • The next step is to build and upload the static files to this bucket. Go to the react project and run the build command.

    npm run build
    

  • For this example, we will use AWS Console to upload files. Later on, we will install and use aws cli in the CI/CD pipeline.

  • Then we need to enable Static website hosting and use index.html for the index document.

  • The last change we need to make before we can access the website is to allow read access to anyone. Go to the Permissions tab and update the Bucket policy. Replace www.devopsbyexample.io with your bucket name.

PublicReadGetObject
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::www.devopsbyexample.io/*"
            ]
        }
    ]
}
  • If you go to bucket endpoint right now, it should return your home page.

Setup Custom Domain for S3 Static Website

Most likely, you would want to add your custom domain to your website. It's relatively easy to do for subdomains if you host your domain outside of AWS. Only www or other subdomains will work. A CNAME cannot be placed at the root domain level because the root domain is the DNS Start of Authority (SOA) which must point to an IP address.

  • Go to your domain hosting provider and create a CNAME record pointing to the S3 bucket endpoint (CNAME must match the bucket name). In my case it will look like this:
NAME                      TYPE   VALUE
------------------------------------------------------------------------------------------
www.devopsbyexample.io.   CNAME  www.devopsbyexample.io.s3-website-us-west-1.amazonaws.com.

Usually, it takes a few minutes to update the DNS. Wait a minute or so and try to use your domain to access the website. Use http://, HTTPS is only supported for CloudFront.

Deploy React App to CloudFront

It's time to deploy it to AWS CloudFront. I'll show you two approaches. One is to keep public access to all the files in the S3 bucket and use its endpoint as an origin. In my experience, this approach works better with custom routing if you have many different URLs and custom redirects. For the second approach, we will use Origin access identities to only allow CloudFront to access S3 objects and use a bucket as an origin.

  • Go to CloudFront and create a new distribution.
  • For the origin, paste the bucket endpoint URL. In my case, http://www.devopsbyexample.io.s3-website-us-west-1.amazonaws.com.
  • Keep Compress objects automatically to improve performance and reduce the cost of the hosting.
  • Select Redirect HTTP to HTTPS for Viewer protocol policy. It's a standard approach. Unless you have a special requirement, stick with it.
  • Add the custom domain names that you use in URLs. For example, you can add www.devopsbyexample.io and devopsbyexample.io.
  • If you want to use HTTPS, you need to request a certificate.
  • Add the same domain names for the certificate request.
  • You need to manually create CNAME records to pass the verification process.
  • Wait till the certificate is valid and select it under Custom SSL certificate.
  • For the Default root object, enter index.html.
  • When the deployment is completed, you can access Distribution domain name to get your website. Now you can use https://.
  • To set up a custom domain, you can update the www subdomain to point to the CloudFront distribution name.
  • You can also inspect the encoding header to make sure it's using a compression algorithm.
    content-encoding: br
    

Transfer Domain to Route53

If you want to use the root domain for your website, you would need to transfer your domain to Route53 and set up an alias.

  • Create Route53 public-hosted zone for your domain. It should match your domain name, in my case, devopsbyexample.io.
  • Then you need to update DNS Name Servers for your domain. Most of the hosting providers allow you to use custom-name servers. Find the way to update them and use name servers presented in your Route53 hosted zone.
  • This procedure may easily take a few hours to complete. You can use the dig command to validate the name servers. Don't proceed until you see the correct values under NS records.
    dig +nssearch devopsbyexample.io
    
  • Now, we need to create DNS records for both the www and root domain in the Route53 hosted zone. Use Aliases in both cases. If you use CNAME, this introduces a performance penalty since at least one additional DNS lookup must be performed to resolve the target.

Setup CI/CD Pipeline for React App using GitHub Actions

It's time to automate the deployment process. We will use GitHub Actions to install dependencies, run some unit tests, upload files to S3 and invalidate the cache.

  • Let's create an empty private GitHub repository and call it www.devopsbyexample.io.
  • Add the remote origin for the React project.
git remote add origin git@github.com:antonputra/www.devopsbyexample.io.git
  • Create GitHub Actions workflow.
.github/workflows/build-and-deploy.yaml
---
name: Build and Deploy React App to CloudFront
on:
  push:
    branches: [ master ]
jobs:
  build-and-deploy:
    name: Build and Deploy
    runs-on: ubuntu-latest
    env:
      BUCKET: <bucket-name>
      DIST: build
      REGION: us-east-1
      DIST_ID: <ID>

    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.REGION }}

    # - uses: actions/setup-node@v2
    #   with:
    #     node-version: '14'

    - name: Install Dependencies
      run: |
        node --version
        npm ci --production

    - name: Build Static Website
      run: npm run build

    - name: Copy files to the production website with the AWS CLI
      run: |
        aws s3 sync --delete ${{ env.DIST }} s3://${{ env.BUCKET }}

    - name: Copy files to the production website with the AWS CLI
      run: |
        aws cloudfront create-invalidation \
          --distribution-id ${{ env.DIST_ID }} \
          --paths "/*"
  • To grant access for GitHub Actions to upload files to S3, we need to create an IAM user and grant appropriate permissions. Let's create S3WebAccess IAM policy. Replace www.devopsbyexample.io with your bucket name.
S3WebAccess
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ListObjectsInBucket",
            "Effect": "Allow",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::www.devopsbyexample.io"
        },
        {
            "Sid": "AllObjectActions",
            "Effect": "Allow",
            "Action": "s3:*Object",
            "Resource": "arn:aws:s3:::www.devopsbyexample.io/*"
        },
        {   
            "Sid": "InvalidateCF",
            "Effect": "Allow",
            "Action": "cloudfront:CreateInvalidation",
            "Resource": "*"
        }
    ]
}
  • Create github-actions user and attach S3WebAccess policy.
  • Go to GitHub repo and create AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets.
  • Make any change in the source code, commit and push.
git add .
git commit -m 'update app'
git push origin master

Restrict Direct Access for S3 Objects (Optionally)

In case you want to restrict direct access for individual S3 objects in your bucket. You can block public access and grant access only to CloudFront.

  • Go to your bucket and disable Static website hosting.
  • Under the Permissions tab, enable Block public access and remove Bucket policy.
  • Go to CloudFront and update the origin to point to the S3 bucket.
top.title