I'm building out a Shedquarters in my backyard! Check out the ever-evolving post + pictures here →

The Perfect Vercel + GitHub Actions Deployment Pipeline

June 10, 2021

As a Laravel developer, I haven't had much of a need for a serverless platform like Vercel. Recently though, I've been working on an API that provides VS Code compatible syntax highlighting and Vercel was the perfect choice for hosting it: fast, secure, and reliable.

Standard Deploying

You can deploy your Vercel app in a couple different ways. One is to deploy from your local machine using the Vercel CLI, by just calling vercel --prod. This makes your app live on the internet in less than a minute, which is kind of insane.

The other option is to hook up your GitHub repo to Vercel and let Vercel deploy every time you push to main.

Both of these options are extremely quick, but make me nervous. I didn't have any confidence that I wasn't deploying a broken build.

Perfect Deploying

In my mind, the perfect strategy would:

  • Run automatically, not be dependent on a human
  • Run my test suite, only deploying if it passes
  • Deploy a preview to Vercel
  • Run the tests again, against the Vercel preview
  • Deploy to production if the tests pass again.

This means that my tests would actually run against my API live on Vercel. It's wonderful to test everything, but it gives me so much more confidence to know that after everything is deployed, it's all still going to work.

It may seem silly, but there is a wide chasm between a local machine and a fully deployed API on Vercel. There is a lot that could go wrong!

The GitHub Actions Workflow

Since the code is all hosted on GitHub, GitHub Actions is the perfect way to implement this strategy. To get a GitHub Action up and running, you just need a .yml file in the ./github/workflows/ folder.

We'll call ours deploy.yml. We're going to walk through it step by step, but here is an overview of it first, with the steps collapsed:

./github/workflows/deploy.yml

1name: Deploy Torchlight Engine
2on: [ push, pull_request ]
3jobs:
4 vercel:
5 runs-on: ubuntu-latest
6 steps:
7 - name: Checkout Code ...
8 uses: actions/checkout@v2
9 
10 - name: Cache Dependencies ...
11 uses: actions/cache@v1
12 with:
13 path: ~/.npm
14 key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
15 restore-keys: ${{ runner.os }}-node-
16 
17 - name: Setup Node 14 ...
18 uses: actions/setup-node@v2
19 with:
20 node-version: '14'
21 
22 - name: Build ...
23 run: |
24 yarn install --frozen-lockfile
25 npm run bundle
26 
27 - name: Run Tests Locally ...
28 run: npm run test
29 
30 - name: Deploy to Staging ...
31 id: deploy-vercel-staging
32 uses: amondnet/vercel-action@v20
33 with:
34 vercel-token: ${{ secrets.VERCEL_TOKEN }}
35 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
36 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
37 scope: ${{ secrets.VERCEL_ORG_ID }}
38 
39 - name: Run Tests Against Vercel ...
40 env:
41 VERCEL_URL: ${{ steps.deploy-vercel-staging.outputs.preview-url }}
42 run: npm run test
43 
44 - name: Deploy to Production ...
45 uses: amondnet/vercel-action@v20
46 id: deploy-vercel-production
47 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
48 with:
49 vercel-token: ${{ secrets.VERCEL_TOKEN }}
50 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
51 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
52 vercel-args: '--prod'
53 scope: ${{ secrets.VERCEL_ORG_ID }}
Code highlighting powered by torchlight.dev.

The first part of the workflow defines a bit of meta for us:

  • the name of the workflow
  • when the action will run (push or pull request)
  • the job definitions

In our case we have one job, named vercel and it runs on the latest Ubuntu.

./github/workflows/deploy.yml

1name: Deploy Torchlight Engine
2on: [ push, pull_request ]
3jobs:
4 vercel:
5 runs-on: ubuntu-latest

Building the World

The first few steps set up our environment and get us ready to be able to do our work. We checkout the repo, restore NPM cache if we can, install Node.js, and then run our own npm run bundle script to get our application ready to be deployed.

(For more about the --frozen-lockfile bit, check out my other post on Reliably Building Assets.)

1steps:
2 - name: Checkout Code ...
3 uses: actions/checkout@v2
4 
5 - name: Cache Dependencies ...
6 uses: actions/cache@v1
7 with:
8 path: ~/.npm
9 key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
10 restore-keys: ${{ runner.os }}-node-
11 
12 - name: Setup Node 14 ...
13 uses: actions/setup-node@v2
14 with:
15 node-version: '14'
16 
17 - name: Build ...
18 run: |
19 yarn install --frozen-lockfile
20 npm run bundle
21 
22 - name: Run Tests Locally ...
23 run: npm run test
24 
25 - name: Deploy to Staging ...
26 id: deploy-vercel-staging
27 uses: amondnet/vercel-action@v20
28 with:
29 vercel-token: ${{ secrets.VERCEL_TOKEN }}
30 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
31 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
32 scope: ${{ secrets.VERCEL_ORG_ID }}
33 
34 - name: Run Tests Against Vercel ...
35 env:
36 VERCEL_URL: ${{ steps.deploy-vercel-staging.outputs.preview-url }}
37 run: npm run test
38 
39 - name: Deploy to Production ...
40 uses: amondnet/vercel-action@v20
41 id: deploy-vercel-production
42 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
43 with:
44 vercel-token: ${{ secrets.VERCEL_TOKEN }}
45 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
46 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
47 vercel-args: '--prod'
48 scope: ${{ secrets.VERCEL_ORG_ID }}

Running the Tests

Before we deploy anything we want to run the tests locally. If the tests don't pass locally they definitely aren't going to pass on Vercel, so we can save ourselves some time. Vercel also has limits on concurrent builds, so it doesn't make sense to clog up the pipeline with builds that we know we are going to abandon.

1steps:
2 - name: Checkout Code ...
3 uses: actions/checkout@v2
4 
5 - name: Cache Dependencies ...
6 uses: actions/cache@v1
7 with:
8 path: ~/.npm
9 key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
10 restore-keys: ${{ runner.os }}-node-
11 
12 - name: Setup Node 14 ...
13 uses: actions/setup-node@v2
14 with:
15 node-version: '14'
16 
17 - name: Build ...
18 run: |
19 yarn install --frozen-lockfile
20 npm run bundle
21 
22 - name: Run Tests Locally ...
23 run: npm run test
24 
25 - name: Deploy to Staging ...
26 id: deploy-vercel-staging
27 uses: amondnet/vercel-action@v20
28 with:
29 vercel-token: ${{ secrets.VERCEL_TOKEN }}
30 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
31 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
32 scope: ${{ secrets.VERCEL_ORG_ID }}
33 
34 - name: Run Tests Against Vercel ...
35 env:
36 VERCEL_URL: ${{ steps.deploy-vercel-staging.outputs.preview-url }}
37 run: npm run test
38 
39 - name: Deploy to Production ...
40 uses: amondnet/vercel-action@v20
41 id: deploy-vercel-production
42 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
43 with:
44 vercel-token: ${{ secrets.VERCEL_TOKEN }}
45 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
46 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
47 vercel-args: '--prod'
48 scope: ${{ secrets.VERCEL_ORG_ID }}

Deploying to Staging

If the local tests pass, then we want to go ahead and deploy a staging site to Vercel. We use a pre-built Vercel GitHub Action to help us out here, so that all we need to do is pass in some secrets.

Check out the GitHub docs for information on how to configure your repo secrets.

This action outputs a preview-url where your app can be reached on Vercel, which is what we're going run our tests against in the next step.

1steps:
2 - name: Checkout Code ...
3 uses: actions/checkout@v2
4 
5 - name: Cache Dependencies ...
6 uses: actions/cache@v1
7 with:
8 path: ~/.npm
9 key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
10 restore-keys: ${{ runner.os }}-node-
11 
12 - name: Setup Node 14 ...
13 uses: actions/setup-node@v2
14 with:
15 node-version: '14'
16 
17 - name: Build ...
18 run: |
19 yarn install --frozen-lockfile
20 npm run bundle
21 
22 - name: Run Tests Locally ...
23 run: npm run test
24 
25 - name: Deploy to Staging ...
26 id: deploy-vercel-staging
27 uses: amondnet/vercel-action@v20
28 with:
29 vercel-token: ${{ secrets.VERCEL_TOKEN }}
30 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
31 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
32 scope: ${{ secrets.VERCEL_ORG_ID }}
33 
34 - name: Run Tests Against Vercel ...
35 env:
36 VERCEL_URL: ${{ steps.deploy-vercel-staging.outputs.preview-url }}
37 run: npm run test
38 
39 - name: Deploy to Production ...
40 uses: amondnet/vercel-action@v20
41 id: deploy-vercel-production
42 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
43 with:
44 vercel-token: ${{ secrets.VERCEL_TOKEN }}
45 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
46 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
47 vercel-args: '--prod'
48 scope: ${{ secrets.VERCEL_ORG_ID }}

Testing Against Staging

Now we're going to test against the fresh deploy we just made to Vercel. The first thing we do is define a new env variable called VERCEL_URL. The value of this URL come from the previous step (deploy-vercel-staging) and is one of the outputs listed in the docs of the Vercel Action.

1steps:
2 - name: Checkout Code ...
3 uses: actions/checkout@v2
4 
5 - name: Cache Dependencies ...
6 uses: actions/cache@v1
7 with:
8 path: ~/.npm
9 key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
10 restore-keys: ${{ runner.os }}-node-
11 
12 - name: Setup Node 14 ...
13 uses: actions/setup-node@v2
14 with:
15 node-version: '14'
16 
17 - name: Build ...
18 run: |
19 yarn install --frozen-lockfile
20 npm run bundle
21 
22 - name: Run Tests Locally ...
23 run: npm run test
24 
25 - name: Deploy to Staging ...
26 id: deploy-vercel-staging
27 uses: amondnet/vercel-action@v20
28 with:
29 vercel-token: ${{ secrets.VERCEL_TOKEN }}
30 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
31 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
32 scope: ${{ secrets.VERCEL_ORG_ID }}
33 
34 - name: Run Tests Against Vercel ...
35 env:
36 VERCEL_URL: ${{ steps.deploy-vercel-staging.outputs.preview-url }}
37 run: npm run test
38 
39 - name: Deploy to Production ...
40 uses: amondnet/vercel-action@v20
41 id: deploy-vercel-production
42 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
43 with:
44 vercel-token: ${{ secrets.VERCEL_TOKEN }}
45 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
46 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
47 vercel-args: '--prod'
48 scope: ${{ secrets.VERCEL_ORG_ID }}

It will change based on the name of your app, but it will inject an environment variable similar to the following:

VERCEL_URL=https://your-app-git-refs-headsmain-hammerstone.vercel.app

With that env variable set, we can read it in our test runner just like every other env var, and switch the endpoint against which we're testing:

tests/runner.js

1let remote = process.env.VERCEL_URL;
2 
3if (remote) {
4 console.log(`Testing against remote url: ${remote}.`);
5} else {
6 console.log('Testing locally only.');
7}
8 
9// Run your tests locally or aginst the preview URL.
Code highlighting powered by torchlight.dev.

With our test runner modified, we can simply run the exact same command to test against staging: npm run test

1steps:
2 - name: Checkout Code ...
3 uses: actions/checkout@v2
4 
5 - name: Cache Dependencies ...
6 uses: actions/cache@v1
7 with:
8 path: ~/.npm
9 key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
10 restore-keys: ${{ runner.os }}-node-
11 
12 - name: Setup Node 14 ...
13 uses: actions/setup-node@v2
14 with:
15 node-version: '14'
16 
17 - name: Build ...
18 run: |
19 yarn install --frozen-lockfile
20 npm run bundle
21 
22 - name: Run Tests Locally ...
23 run: npm run test
24 
25 - name: Deploy to Staging ...
26 id: deploy-vercel-staging
27 uses: amondnet/vercel-action@v20
28 with:
29 vercel-token: ${{ secrets.VERCEL_TOKEN }}
30 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
31 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
32 scope: ${{ secrets.VERCEL_ORG_ID }}
33 
34 - name: Run Tests Against Vercel ...
35 env:
36 VERCEL_URL: ${{ steps.deploy-vercel-staging.outputs.preview-url }}
37 run: npm run test
38 
39 - name: Deploy to Production ...
40 uses: amondnet/vercel-action@v20
41 id: deploy-vercel-production
42 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
43 with:
44 vercel-token: ${{ secrets.VERCEL_TOKEN }}
45 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
46 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
47 vercel-args: '--prod'
48 scope: ${{ secrets.VERCEL_ORG_ID }}

If you check the logs of your GitHub Action, you'll see something similar to this:

1Run npm run test
2
3> @ test /home/runner/work/torchlight-engine/torchlight-engine
4> node ./tests/run.js
5
6************************
7* Running Tests *
8************************
9
10Testing against remote url: https://torchlight-XXXX-hammerstone.vercel.app.
11
12681ms [PASS] /tests/fixtures/summary-detail-with-class.txt
13695ms [PASS] /tests/fixtures/yaml-with-config.txt
14734ms [PASS] /tests/fixtures/focus-line-ranges.txt
15755ms [PASS] /tests/fixtures/blade.txt
16717ms [PASS] /tests/fixtures/min-dark-has-git-diff-colors.txt
17770ms [PASS] /tests/fixtures/summary-detail.txt
18708ms [PASS] /tests/fixtures/relative-double-range.txt
19750ms [PASS] /tests/fixtures/focus-lines.txt
20740ms [PASS] /tests/fixtures/relative-range-positive.txt
21760ms [PASS] /tests/fixtures/relative-range-negative.txt
22698ms [PASS] /tests/fixtures/no-lang.txt
23755ms [PASS] /tests/fixtures/relative-double-range-2.txt
24814ms [PASS] /tests/fixtures/moonlight-ii.txt
25846ms [PASS] /tests/fixtures/yaml-ranges-were-out-of-order.txt
26777ms [PASS] /tests/fixtures/solarized-light-has-line-number-colors.txt
27842ms [PASS] /tests/fixtures/adds-classes.txt
28940ms [PASS] /tests/fixtures/cpp-comment-is-removed.txt
29 [..... Lots more tests]
30Done.

We've now successfully run tests against the environment that we actually care about.

The last thing we have to do is deploy to production. Remember that the entire job will fail if one of the steps fail, so we don't have to check for that.

This is almost identical to the Deploy to Staging action, save for two things. The first is that we add an if condition into the step definition to only deploy to production if we're on the main branch. We'll run tests against staging on all branches, but we obviously only ever want to deploy to production for our main branch.

Make sure that you do not have the setting enabled in Vercel that auto-deploys your main branch. We will be manually deploying from this action!

The second thing we add is the vercel-args: '--prod', which is what tells Vercel to make this a production deployment.

1steps:
2 - name: Checkout Code ...
3 uses: actions/checkout@v2
4 
5 - name: Cache Dependencies ...
6 uses: actions/cache@v1
7 with:
8 path: ~/.npm
9 key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
10 restore-keys: ${{ runner.os }}-node-
11 
12 - name: Setup Node 14 ...
13 uses: actions/setup-node@v2
14 with:
15 node-version: '14'
16 
17 - name: Build ...
18 run: |
19 yarn install --frozen-lockfile
20 npm run bundle
21 
22 - name: Run Tests Locally ...
23 run: npm run test
24 
25 - name: Deploy to Staging ...
26 id: deploy-vercel-staging
27 uses: amondnet/vercel-action@v20
28 with:
29 vercel-token: ${{ secrets.VERCEL_TOKEN }}
30 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
31 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
32 scope: ${{ secrets.VERCEL_ORG_ID }}
33 
34 - name: Run Tests Against Vercel ...
35 env:
36 VERCEL_URL: ${{ steps.deploy-vercel-staging.outputs.preview-url }}
37 run: npm run test
38 
39 - name: Deploy to Production ...
40 uses: amondnet/vercel-action@v20
41 id: deploy-vercel-production
42 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
43 with:
44 vercel-token: ${{ secrets.VERCEL_TOKEN }}
45 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
46 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
47 vercel-args: '--prod'
48 scope: ${{ secrets.VERCEL_ORG_ID }}

All Tests Pass

Now every time you push to main, you will see the following results if everything passes:

GitHub Actions Vercel

Go forth and merge with confidence!


Here is the entire, uncollapsed YAML file in case you want to copy paste it:

1name: Deploy Torchlight Engine
2on: [ push, pull_request ]
3jobs:
4 vercel:
5 runs-on: ubuntu-latest
6 steps:
7 - name: Checkout Code
8 uses: actions/checkout@v2
9 
10 - name: Cache Dependencies
11 uses: actions/cache@v1
12 with:
13 path: ~/.npm
14 key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
15 restore-keys: ${{ runner.os }}-node-
16 
17 - name: Setup Node 14
18 uses: actions/setup-node@v2
19 with:
20 node-version: '14'
21 
22 - name: Build
23 run: |
24 yarn install --frozen-lockfile
25 npm run bundle
26 
27 - name: Run Tests Locally
28 run: npm run test
29 
30 - name: Deploy to Staging
31 id: deploy-vercel-staging
32 uses: amondnet/vercel-action@v20
33 with:
34 vercel-token: ${{ secrets.VERCEL_TOKEN }}
35 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
36 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
37 scope: ${{ secrets.VERCEL_ORG_ID }}
38 
39 - name: Run Tests Against Vercel
40 env:
41 VERCEL_URL: ${{ steps.deploy-vercel-staging.outputs.preview-url }}
42 run: npm run test
43 
44 - name: Deploy to Production
45 uses: amondnet/vercel-action@v20
46 id: deploy-vercel-production
47 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
48 with:
49 vercel-token: ${{ secrets.VERCEL_TOKEN }}
50 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
51 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_TL_ENGINE }}
52 vercel-args: '--prod'
53 scope: ${{ secrets.VERCEL_ORG_ID }}
Thanks for reading! My name is Aaron and I'm currently working at small property tax firm in Texas called Resolute Property Tax Solutions, where I serve in dual roles as COO & CTO.

I work on a lot of projects. I'm building a shedquarters. I currently do a podcast, and I used to do a different podcast.

If you ever have any questions or want to chat, I'm always on Twitter
Copyright 2013 - 2021, Aaron Francis.