Running Tighten's Jigsaw on Vercel

• 9 min read

For more than two years, this website was hosted on Netlify. This summer, the poor loading performance got on my nerves, and I decided to migrate the site to Vercel. The results are stunning! The average response time dropped from ~500ms to ~100ms.

Screenshot of a chart showing a drastic change in average response time for stefanzweifel.dev.
The improved performance is well visualized through OhDear.app. Now in December, the average response time is even lower than 50ms.

My site is built with Jigsaw, a PHP static site builder.
That is the first problem: PHP. You can't run a PHP binary in Vercel's build process. The community PHP runtime can't be used either: I need to run a PHP CLI; not deploy a PHP lambda function.

So how did I deploy this site to Vercel? If you know me well, you know the answer already: GitHub Actions.

I've created a GitHub Actions workflow that checks out my repository, installs the composer and NPM dependencies, builds the site, tests the site for common errors and then – finally – uploads the site to Vercel's edge network.

Screenshot of a pull request on GitHub showing a deploy preview.
By using GitHub environments, a button can be added to the pull request view which points to the deploy preview on Vercel. Neat!

Before we begin: this setup looks intimidating. If you would like to host your Jigsaw websites in a simpler way, I can recommend following Michael Dyrynda's blog post on how to deploy a Jigsaw site to Netlify.

GitHub Actions workflow to deploy the site #

Before switching to Vercel as my website host, I already used GitHub Actions to create a new build of the website on every commit push. The workflow acted like a test runner. It ensured, that my changes – or dependeny updates created by Dependabot – didn't break the site.

All I had to add was a new job that uploaded the prepared website to Vercel. Below is the stripped down workflow annotated with comments, to explain what each step does.
I've removed a couple of steps that do the quality control (look for weasel words, validate RSS feed, etc.) for me. I will share more about these steps in the future.

To use the workflow you will need to create a couple of secrets in your repository. You will need a VERCEL_TOKEN, VERCEL_PROJECT_ID and VERCEL_ORG_ID.

# integrate.yml
name: Integrate

# Run workflow when commits are pushed to pull requests or main branch
on:
    pull_request: null
    push:
        branches:
            - main

jobs:
    # Build Job
    # This job install necessary dependencies, generate a new production build
    # of the website and make the build available to other jobs in the workflow.
    build:
        name: Build
        runs-on: ubuntu-latest

        steps:
            - uses: actions/checkout@v2

            - name: Restore node_modules folder
              id: cache-node
              uses: actions/cache@v2
              with:
                  path: node_modules
                  key: ${{ runner.os }}-node-v2-${{ hashFiles('**/yarn.lock') }}
                  restore-keys: |
                      ${{ runner.os }}-node-v2

            - name: Install frontend dependencies
              if: steps.cache-node.outputs.cache-hit != 'true'
              run: yarn install
              env:
                  CI: true

            - name: Create production build of CSS and JS
              run: yarn run prod

            - name: Restore Composer dependencies
              id: cache-php
              uses: actions/cache@v2
              with:
                  path: vendor
                  key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
                  restore-keys: |
                      ${{ runner.os }}-php-

            # If no Composer cache exists, install Composer dependencies
            - name: Install Composer dependencies
              if: steps.cache-php.outputs.cache-hit != 'true'
              run: composer install -n --ignore-platform-reqs

            - name: Create new production build of website
              run: composer run build:prod

            - name: Create ZIP file of production build
              run: zip -r build_production.zip build_production/

            - name: Keep ZIP file as artifact
              uses: actions/upload-artifact@v2
              with:
                  name: build_production_zip
                  path: build_production.zip
                  retention-days: 1

    # Deploy Preview Job
    # This job will deploy the website to a preview domain and is only executed
    # when a commit has been pushed to a pull request and the author of the pull
    # request is not Dependabot.
    deploy_preview:
        name: Deployment Preview
        if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
        runs-on: ubuntu-latest

        # The job is only run, when the `build`-job is finished.
        needs:
            - build

        steps:
            - uses: actions/checkout@v2

            - name: Download build artifact
              uses: actions/download-artifact@v2
              with:
                  name: build_production_zip
                  path: ./build

            - name: Unzip production build
              run: unzip ./build/build_production.zip -d ./build_production

            # Start tracking the deployment status using GitHub deployments
            - name: Start deployment
              uses: bobheadxi/deployments@master
              id: deployment_pr
              with:
                  step: start
                  token: ${{ secrets.GITHUB_TOKEN }}

                  env: 'Pull Request #${{ github.event.number }} Preview'
                  # `head_ref` has to be used here, as otherwhise the
                  # deployments are not shown near the status overview inside
                  # a pull request
                  ref: ${{ github.head_ref }}

            - name: Setup Node.js
              uses: actions/setup-node@v2
              with:
                  node-version: 16

            - name: Restore node_modules folder
              id: cache-node
              uses: actions/cache@v2
              with:
                  path: node_modules
                  key: ${{ runner.os }}-node-v2-${{ hashFiles('**/yarn.lock') }}
                  restore-keys: |
                      ${{ runner.os }}-node-v2

            - name: Install frontend dependencies
              if: steps.cache-node.outputs.cache-hit != 'true'
              run: yarn install
              env:
                  CI: true

            # Deploy website as a preview to Vercel.
            - name: Deploy to Vercel
              uses: amondnet/vercel-action@v20
              id: vercel_action_pr
              with:
                  vercel-token: ${{ secrets.VERCEL_TOKEN }}
                  github-token: ${{ secrets.GITHUB_TOKEN }}
                  vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
                  vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
                  # Disable GitHub comments. I don't need a comment telling me
                  # that a deployment is happening.
                  github-comment: false
                  # This is not a typo. This structure is created by unzipping
                  # the production build artifact.
                  working-directory: ./build_production/build_production

            # Set the deployment status in GitHub to finished.
            - name: Update Deployment Status
              uses: bobheadxi/deployments@master
              if: always()
              with:
                  step: finish
                  token: ${{ secrets.GITHUB_TOKEN }}
                  status: ${{ job.status }}
                  # We use the deployment ID of a previous step here
                  deployment_id: ${{ steps.deployment_pr.outputs.deployment_id }}
                  # We pass Vercel's own preview URL to the environment. This
                  # way we can easily visit the deployed site from the pull
                  # request.
                  env_url: ${{ steps.vercel_action_pr.outputs.preview-url }}

            - name: Delete production build artifact
              uses: geekyeggo/delete-artifact@v1
              if: always()
              with:
                  name: build_production_zip

    # Deploy Production Job
    # This job will deploy the website to production and will only be executed
    # when a commit is pushed to the default `main`-branch.
    deploy_prod:
        name: Deployment
        if: github.event.ref == 'refs/heads/main'
        runs-on: ubuntu-latest

        # The job is only run, when the `build`-job is finished.
        needs:
            - build

        steps:
            - uses: actions/checkout@v2

            - name: Download build artifact
              uses: actions/download-artifact@v2
              with:
                  name: build_production_zip
                  path: ./build

            - name: Unzip production build
              run: unzip ./build/build_production.zip -d ./build_production

            # Start tracking the deployment status using GitHub deployments.
            # Instead of using a dynamic environment name, we use production
            - name: Start Deployment
              uses: bobheadxi/deployments@master
              id: deployment
              with:
                  step: start
                  token: ${{ secrets.GITHUB_TOKEN }}
                  env: production

            - uses: actions/setup-node@v2
              with:
                  node-version: 16

            - name: Restore node_modules folder
              id: cache-node
              uses: actions/cache@v2
              with:
                  path: node_modules
                  key: ${{ runner.os }}-node-v2-${{ hashFiles('**/yarn.lock') }}
                  restore-keys: |
                      ${{ runner.os }}-node-v2

            - name: Install frontend dependencies
              if: steps.cache-node.outputs.cache-hit != 'true'
              run: yarn install
              env:
                  CI: true

            # Deploy website for production to Vercel.
            - name: Deploy to Vercel
              uses: amondnet/vercel-action@v20
              id: vercel-action
              with:
                  vercel-token: ${{ secrets.VERCEL_TOKEN }}
                  github-token: ${{ secrets.GITHUB_TOKEN }}
                  vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
                  vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
                  github-comment: false
                  working-directory: ./build_production/build_production
                  # This argument tells the Vercel Action to mark the deployment
                  # for production
                  vercel-args: '--prod'

            # Set the deployment status in GitHub to finished.
            - name: Update Deployment Status
              uses: bobheadxi/deployments@master
              if: always()
              with:
                  step: finish
                  token: ${{ secrets.GITHUB_TOKEN }}
                  status: ${{ job.status }}
                  deployment_id: ${{ steps.deployment.outputs.deployment_id }}
                  env_url: ${{ steps.vercel-action.outputs.preview-url }}

            - name: Delete production build artifact
              uses: geekyeggo/delete-artifact@v1
              if: always()
              with:
                  name: build_production_zip

Add the root of my project, I have the following vercel.json file.

// vercel.json
{
    "github": {
        "enabled": false,
        "silent": true
    }
}

By setting github.enabled to false, I tell Vercel to not automatically deploy my site on every push through Vercel. GitHub Actions does that for me. By setting github.silent to true, Vercel will stop commenting on commits and pull requests.

Deactivate Environments when closing Pull Requests #

The big workflow above creates a new GitHub environment whenever a pull request is being deployed.
These environments will stay arround forever, if we don't deactivate them.

If you would like to keep things tidy, you can add this additional workflow to your repository. It will be triggered when a pull request is closed or merged and will delete the environment associated with it. (If you have updated the environment name in the workflow above, you have to update this workflow as well.)

# prune-environments.yml
on:
    pull_request:
        types: [closed]

jobs:
    prune:
        runs-on: ubuntu-latest

        steps:
            - name: Deactivate GitHub environment
              uses: bobheadxi/[email protected]
              with:
                  step: deactivate-env
                  token: ${{ secrets.GITHUB_TOKEN }}
                  env: 'Pull Request #${{ github.event.number }} Preview'
                  desc: Deployment was pruned

Wrap up #

It took a couple of tries to get the workflow to behave as expected; but I really like the end result. I'm now much more in control how the website is built and have access to a more frequently updated Linux machine (another reason I was looking for a different host, as the Netlify base image was stuck on PHP 7.4).

The best thing: The workflow can be applied to any other language.
A framework you like is not compatible with Vercel? Use the workflow above and adjust it so that the build step uses the language or tool of your choosing.

Another cool benefit of having everything in GitHub Actions? I can just chain other Actions to this workflow.
For example, after each deploy I automatically run an Oh Dear check to check if any link on my website is broken.

I hope this article inspired you to give Vercel and GitHub Actions a try. If you have any questions regarding the workflow, let me know!