Goal#
We want a CI pipeline that will build and deploy an instance of our frontend application for every PR created in our frontend repo. Additionally, we want to be able to easily spin up applications with overridden configuration to allow developers to test the frontend against experimental backends. Finally, we want a reporting mechanism to inform developers when and where these deployed environments are available.
Other Options#
Before you jump into this, consider that there are out-of-the-box solutions to solve this problem mentioned in the followup at the bottom of this page.
Background: Project Infrastructure#
Our production frontend is a React application (using Next.js). We build this application into static HTML/CSS/JS files and upload them to an S3 bucket. This bucket has been setup to serve static websites and is served via HTTPS by CloudFront (see #270).
The frontend accesses multiple backend APIs (e.g. a STAC API, a FastAPI REST API). Deployment of those APIs is outside of the scope of the frontend codebase.
CI: Cloud Infrastructure#
Our goal is to mimic the production frontend deployment to a reasonable degree.
1. Setup a bucket#
First, we will need an S3 bucket to store our builds. We created a bucket (s3://project-frontend-ci
) and configured it to serve static websites. As a sanity check, we wrote a simple message to a public file that we place at the root of the bucket:
1
| echo "Project frontend CI builds" | aws s3 cp - s3://project-frontend-ci/index.html --acl public-read --content-type text/html
|
We can verify that everything is configured properly by visiting the bucket’s website url: http://project-frontend-ci.s3-website.us-east-1.amazonaws.com/
2. Setup SSL via API Gateway#
Unfortunately, S3 does not support serving content via SSL (i.e. over HTTPS). As such, we need to use something like API Gateway or CloudFront to accept traffic over HTTPS and to route it to our bucket’s content over HTTP. For the sake of simplicity, we chose to use API Gateway for this purpose. For our actual staging environment, we utilize CloudFront to more closely mimic the production environment, however for these temporary CI builds we felt that API Gateway was close-enough.
We created an API Gateway HTTP API configured to direct all traffic to our S3 bucket’s website URL:
We can now visit our bucket over SSL via the API Gateway endpoint: https://0zy9z5ko27.execute-api.us-east-1.amazonaws.com/
3. Setup a URL for the CI builds#
The downside of API Gateway or CloudFront is that it produces very unmemorable URLs. Just to be a bit fancy 💅 , we set up a custom URL on domain.com
. We settled on ci.project-staging.domain.com
to pair nicely with our staging environment (project-staging.domain.com
).
Setting this up is a bit of a multi-step process. Click here to see the details...
a. Create SSL Certificate#
On the AWS account owns the API Gateway HTTP API we just setup, we created an SSL Certificate via AWS Certificate Manager (ACM):
b. Verify ownership of domain#
ACM requires that you verify that you have control of a domain before it will grant you an SSL certificate. After creating an SSL certificate, you’ll see that it is in “Pending Validation” status.
To verify that we control domain.com
, we add a CNAME record to the domain.com
hosted zone. Once this is done, we frantically refresh the ACM status page until it states that our domain has been verified.
c. Setup API Gateway custom domain#
Back over to API Gateway, we set up a custom domain.
After creating the custom domain, we add an API mapping to our HTTP API.
d. Creating a DNS entry for our new URL#
We now want to instruct Route53 to direct all traffic sent to our URL (ci.project-staging.domain.com
) to our new API Gateway custom domain. To do this, we copy the API Gateway domain name.
We use the copied API Gateway domain name to create a new DNS entry to facilitate this mapping:
After this, we should be able to see our sanity-check message at https://ci.project-staging.domain.com.
CI: Workflows#
Building & Deploying#
Our goal is to build a version of our frontend application for every pull request and have it available at https://ci.project-staging.domain.com. Our chosen strategy was to build each PR and to place the build in a path prefixed with the PR number (i.e. PR 208 should be available at https://ci.project-staging.domain.com/208). To facilitate this, your frontend application must be configured to allow it to be served from non-route paths. In the case of NextJS, this is done via the Base Path configuration.
Building and Deploying the application via Github actions is pretty straightforward.
Example of a simple build/deploy Github Actions workflow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| name: Deploy to CI environment
on:
pull_request:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Checkout
uses: actions/checkout@v2
- name: Use Node.js 14
uses: actions/setup-node@v1
with:
node-version: 14
- name: Cache node modules
uses: actions/cache@v2
env:
cache-name: cache-node-modules
with:
path: node_modules
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Build and Export
id: build
env:
NEXT_PUBLIC_BASE_URL: https://ci.project-staging.domain.com/${{ github.event.pull_request.number }}
NEXT_PUBLIC_STAC_API: ${{ 'https://project-staging.domain.com/stac' }}
NEXT_PUBLIC_ORDERS_API: ${{ 'https://project-staging.domain.com/api' }}
run: |
yarn install
yarn build
yarn run next export
- name: Configure AWS credentials from staging account
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.STAGING_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.STAGING_AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Deploy 🚀
run: |
aws s3 sync \
./out \
s3://project-frontend-ci/${{ github.event.pull_request.number }} \
--delete \
--acl public-read
|
You can see that we pass in our Base URL and external APIs via the env
at build time and that we have our AWS credentials available as encrypted secrets.
Note that, as per the Github docs, the pull_request
event only triggers when a PR is opened, updated, or re-opened:
By default, a workflow only runs when a pull_request
’s activity type is opened
, synchronize
, or reopened
.
Once the CI has built and deployed a new instance of our frontend, we want to notify others (e.g. those reviewing PRs) where they can view the build. To do this, we add the following steps to our Github Actions workflow to take place after our build:
Example of jobs to add comments to a PR
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| jobs:
build-and-deploy:
steps:
# ...
- name: Get current time
uses: gerred/actions/current-time@master
id: current-time
- name: Find Comment
uses: peter-evans/find-comment@v1
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: "github-actions[bot]"
body-includes: Latest commit deployed to
- name: Create or update comment
uses: peter-evans/create-or-update-comment@v1
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
🚀 Latest commit deployed to https://ci.project-staging.domain.com/${{ github.event.pull_request.number }}
* Date: `${{ steps.current-time.outputs.time }}`
* Commit: ${{ github.sha }} (Merging ${{ github.event.pull_request.head.sha }} into ${{ github.event.pull_request.base.sha }})
edit-mode: replace
|
These steps add a new comment to PRs, looking something like this:
For later commits to the PR, the original comment will be replaced rather than creating another comment. This helps us avoid littering user’s notifications and keeps a clean comment thread.
Allow users to customize configuration#
As previously mentioned, the frontend connects to multiple backing APIs. By default, the CI builds point to our staging APIs. However, it’s a realistic scenario that a developer would want their custom environment to point to a different API release (e.g. a developer is working on frontend changes in tandem with changes being made to the backend API). To support this, we want to allow developers to manually override certain configurations. To do this, we added the workflow_dispatch
trigger to our workflow, allowing for manual workflow runs. We also add inputs
for each configuration we want to allow a developer to specify.
Example of adding workflow_dispatch
event with inputs
to your workflow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| name: Deploy to CI environment
on:
pull_request:
workflow_dispatch:
inputs:
stac-api-url:
description: Override STAC API URL
default: https://project-staging.domain.com/stac
orders-api-url:
description: Override Orders API URL
default: https://project-staging.domain.com/api
deployment-id:
description: Unique identifier for build (used to construct path for upload)
required: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
# ...
- name: Build and Export
id: build
env:
NEXT_PUBLIC_BASE_URL: https://ci.project-staging.domain.com/${{ github.event.inputs.deployment-id || github.event.pull_request.number }}
NEXT_PUBLIC_STAC_API: ${{ github.event.inputs.stac-api-url || 'https://project-staging.domain.com/stac' }}
NEXT_PUBLIC_ORDERS_API: ${{ github.event.inputs.orders-api-url || 'https://project-staging.domain.com/api' }}
NEXT_PUBLIC_MB_TOKEN: pk.eyJ1IjoiZGV2c2VlZCIsImEiOiJjazB6YXU2bDUwMWNkM2VvNGNpMnFhOXMxIn0.c30a2TQIfCDF3GlqMdSQ_g
NEXT_PUBLIC_GA_ID: GTM-WNP7MLF
run: |
yarn install
yarn build
yarn run next export
- name: Get current time
uses: gerred/actions/current-time@master
if: ${{ github.event.pull_request.number }}
# ...
- name: Find Comment
uses: peter-evans/find-comment@v1
if: ${{ github.event.pull_request.number }}
# ...
- name: Create or update comment
uses: peter-evans/create-or-update-comment@v1
if: ${{ github.event.pull_request.number }}
# ...
|
You’ll note that any place where we originally specified our API configuration or had a dependency on a PR number, we now first try to retrieve the value from github.event.inputs
(only available during manual workflow_dispatch
events) and otherwise fall back to values used during standard PR builds. This can be achieved by utilizing the OR operator as so: ${{ github.event.inputs.deployment-id || github.event.pull_request.number }}
.
Additionally, we will only want to comment on a PR during PR builds, so we avoid running the comment steps by adding an if: ${{ github.event.pull_request.number }}
clause to each step that we want to skip.
Cleanup#
After each PR is merged, we want to clean up the past build to avoid unnecessary storage in our CI bucket. This can be achieved with another workflow that cleans up the build whenever a pull request is closed.
Example of a workflow to destroy CI builds
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| name: Destroy PR Preview
on:
pull_request:
types: [closed]
workflow_dispatch:
inputs:
deployment-id:
description: Unique identifier of CI build to be deleted
required: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
# ...
- name: Destroy 💣
run: |
aws s3 rm --recursive s3://project-frontend-ci/${{ github.event.inputs.deployment-id || github.event.pull_request.number }}/
- name: Get current time
uses: gerred/actions/current-time@master
if: ${{ github.event.pull_request.number }}
id: current-time
- name: Find Comment
uses: peter-evans/find-comment@v1
if: ${{ github.event.pull_request.number }}
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: "github-actions[bot]"
body-includes: Latest commit deployed to
- name: Create or update comment
uses: peter-evans/create-or-update-comment@v1
if: ${{ github.event.pull_request.number }}
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
---
🧹 Deleted build at https://ci.project-staging.domain.com/${{ github.event.inputs.deployment-id || github.event.pull_request.number }}
* Date: `${{ steps.current-time.outputs.time }}`
edit-mode: append
|
Our pull request message is then appended with information to let others know that the build environment is no longer available.
Putting it all together#
To achieve our goals of deployment, notification, customization, and cleanup, we have settled on these two Github workflows:
.github/workflows/deploy-pr-preview.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
| name: Deploy to CI environment
on:
pull_request:
workflow_dispatch:
inputs:
stac-api-url:
description: Override STAC API URL
default: https://project-staging.domain.com/stac
orders-api-url:
description: Override Orders API URL
default: https://project-staging.domain.com/api
deployment-id:
description: Unique identifier for build (used to construct path for upload)
required: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Checkout
uses: actions/checkout@v2
- name: Use Node.js 14
uses: actions/setup-node@v1
with:
node-version: 14
- name: Cache node modules
uses: actions/cache@v2
env:
cache-name: cache-node-modules
with:
path: node_modules
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Build and Export
id: build
env:
NEXT_PUBLIC_BASE_URL: https://ci.project-staging.domain.com/${{ github.event.inputs.deployment-id || github.event.pull_request.number }}
NEXT_PUBLIC_STAC_API: ${{ github.event.inputs.stac-api-url || 'https://project-staging.domain.com/stac' }}
NEXT_PUBLIC_ORDERS_API: ${{ github.event.inputs.orders-api-url || 'https://project-staging.domain.com/api' }}
NEXT_PUBLIC_MB_TOKEN: pk.eyJ1IjoiZGV2c2VlZCIsImEiOiJjazB6YXU2bDUwMWNkM2VvNGNpMnFhOXMxIn0.c30a2TQIfCDF3GlqMdSQ_g
NEXT_PUBLIC_GA_ID: GTM-WNP7MLF
run: |
yarn install
yarn build
yarn run next export
- name: Configure AWS credentials from staging account
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.STAGING_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.STAGING_AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Deploy 🚀
run: |
aws s3 sync \
./out \
s3://project-frontend-ci/${{ github.event.inputs.deployment-id || github.event.pull_request.number }} \
--delete \
--acl public-read
- name: Get current time
uses: gerred/actions/current-time@master
if: ${{ github.event.pull_request.number }}
id: current-time
- name: Find Comment
uses: peter-evans/find-comment@v1
if: ${{ github.event.pull_request.number }}
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: "github-actions[bot]"
body-includes: Latest commit deployed to
- name: Create or update comment
uses: peter-evans/create-or-update-comment@v1
if: ${{ github.event.pull_request.number }}
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
🚀 Latest commit deployed to https://ci.project-staging.domain.com/${{ github.event.inputs.deployment-id || github.event.pull_request.number }}
* Date: `${{ steps.current-time.outputs.time }}`
* Commit: ${{ github.sha }} (merging ${{ github.event.pull_request.head.sha }} into ${{ github.event.pull_request.base.sha }})
edit-mode: replace
|
.github/workflows/destroy-pr-preview.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
| name: Destroy PR Preview
on:
pull_request:
types: [closed]
workflow_dispatch:
inputs:
deployment-id:
description: Unique identifier of CI build to be deleted
required: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- name: Configure AWS credentials from staging account
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.STAGING_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.STAGING_AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Destroy 💣
run: |
aws s3 rm --recursive s3://project-frontend-ci/${{ github.event.inputs.deployment-id || github.event.pull_request.number }}/
- name: Get current time
uses: gerred/actions/current-time@master
if: ${{ github.event.pull_request.number }}
id: current-time
- name: Find Comment
uses: peter-evans/find-comment@v1
if: ${{ github.event.pull_request.number }}
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: "github-actions[bot]"
body-includes: Latest commit deployed to
- name: Create or update comment
uses: peter-evans/create-or-update-comment@v1
if: ${{ github.event.pull_request.number }}
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
---
🧹 Deleted build at https://ci.project-staging.domain.com/${{ github.event.inputs.deployment-id || github.event.pull_request.number }}
* Date: `${{ steps.current-time.outputs.time }}`
edit-mode: append
|
This system is very new for us and required some additional changes to other services (i.e. updating CORS rules on our APIs to allow us to use this new URL), but so far it seems to be operating as expected. Hoping that this can help others who are looking to build out better CI preview environments for their applications.
Followup#
this is super cool but was there a reason we couldn’t use netlify or another third party service for this?
This is a fair question. We opted to roll our own solution being that the general idea (uploading builds to S3) was something that we were already doing for deployments to our Production and Staging environments. However, if you’re starting a new project, you may be interested in achieving per-PR deployments with a third party tool. It appears that most common hosting solutions offer something for this: