A static web application is an application delivered directly to an end user’s browser without any server-side modification. While a web server is still used, it primarily serves application files directly from disk. For example, an incoming request to https://mywebsite.com/ would result in returning the index.html file from the server root directory. If the returned HTML document references any additional assets (e.g images, css, javascript files), additional requests to the web server would be made, and those files would be resolved from their relative location on the filesystem.

In this post, we’ll look at deploying a static site created with Create React App to AWS via Github Actions. We’ll require the following AWS services:

Configuring the AWS services is outside the scope of this article. I highly recommend leveraging the CloudPosse AWS CloudFront/S3 CDN module or reading through this excellent blog post and configuring the resources via Terraform manually.

Let’s get started with our build.yml definition. This file will live in the .github/workflows/ directory, and should be committed into your repository (and not .gitignored). Let’s start by giving our job a name and defining when it’s run.

name: Build and publish

on:
  push:
    tags:
      - '*'

For the above configuration, our job will be run anytime a new tag is published. Git tags are a great way to manage code releases. Ideally artifacts are not modified in-between environments. Instead, a single artifact progresses from int → stg → prd. Unfortunately, this isn’t always possible for client/static applications. Static applications typically inject configuration during their build stage. For example, the HTTP endpoint for your API generally differs between pre-production and production. Additionally, pre-production static sites can opt to enable debugging aids such as sourcemaps, which would typically be disabled for production.

With these limitations, we’ll opt for rebuilding the application for each environment, and use git tags to identify which environment is being targeted. Let’s take a look at the entire build definition:


name: Build and publish

on:
  push:
    tags:
      - '*'

jobs:
  build:
    runs-on: ubuntu-latest

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

      - name: Set TARGET_ENV
        run: |
          if [[ '${{ github.ref_name }}' =~ -(int|prd)$ ]]; then
            TARGET_ENV=${BASH_REMATCH[1]}
            echo "TARGET_ENV=$TARGET_ENV" >> $GITHUB_ENV
          else
            echo "Invalid tag"
            exit 1;
          fi

      - name: Install dependencies
        uses: actions/setup-node@v2
        with:
          node-version: '16.x'
          cache: 'npm'
      - run: npm install

      - name: Build project
        run: |
          cp ".env.${TARGET_ENV}" .env
          npm run build

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-region: ${{ fromJSON(secrets.AWS_CREDENTIALS)[env.TARGET_ENV].region }}
          aws-access-key-id: ${{ fromJSON(secrets.AWS_CREDENTIALS)[env.TARGET_ENV].accessKeyId }}
          aws-secret-access-key: ${{ fromJSON(secrets.AWS_CREDENTIALS)[env.TARGET_ENV].secretAccessKey }}

      - name: Deploy
        run: |
          aws s3 sync build/ s3://${{ fromJSON(vars.BUILD_CONFIG)[env.TARGET_ENV].bucket }}
          aws cloudfront create-invalidation --distribution-id ${{ fromJSON(vars.BUILD_CONFIG)[env.TARGET_ENV].distributionId}}--paths "/"

Our build has a total of six steps.

{
  "int": {
    "region": "...",
    "accessKeyId": "...",
    "secretAccessKey": "..."
  },
  "prd": {...}
}
{
  "int": {
    "bucket": "...",
    "distributionId": "..."
  },
  "prd": {}
}

And that’s it! By using Github secrets & variables, we are able to keep our build definition generic and reusable for other applications. Additionally, adding environments or updating build parameters doesn’t require committing code, and can be done by updating our repository settings. By leveraging .env files, we’re able to modify our artifact for a given environment, enabling things like source maps in pre-production (e.g by adding GENERATE_SOURCEMAP=true in .env.int) while ensuring our production build is optimized.