Running Tighten's Jigsaw on Vercel

• 3 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.io.
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/deployments@v0.4.3
                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!

Webmentions