Setting up Automatic Build & Deploy of a Jekyll Blog

7 minute read

In the previous post I demonstrated how to set up a spellcheck & lint action for a Jekyll powered site. The final piece in our GitHub actions automation puzzle is to build & deploy the site without having to do anything manually. The goal is to create the following actions:

  • On every pull request, run a jekyll build to assert that the site can be build successfully ✅
  • On every push to master, run jekyll build to produce the static site 📦
  • On every push to master, deploy the site using SSH 🚀
  • On every push to master, purge the Cloudflare cache 💨

Just as before, I will be using GitHub actions.

Assumptions

As of the time of writing, my blog is hosted on a web server controlled entirely by me. This means that deploying the site is as easy as copying files over SSH. Your setup may vary (and mine will certainly change as well - I always have plans for improvement 😀). You don’t have to do it the same way I do, here are some alternative ways to host a Jekyll site:

  • Amazon S3 (probably the cheapest for low traffic blogs)
  • GitHub Pages (low entry barrier, very solid GitHub integration)
  • Other platforms

For me this blog is more of a fun learning experience so I will go the 100% manual way. Of course manual does not mean repetitive, the goal still is to automate as much as possible. I just also want to write as much automation myself as is worthwhile 😛

Let’s get to it!

Build Action

The goal of this workflow is to build the Jekyll site, just to see if it’s successful or not. We will take care of the actual deployment in a later action, only to be performed when pushing to the master branch. However it is still worthwhile to run a jekyll build to ensure that the site can be built, and that Jekyll does not error out. This way we can ensure that code that is merged to staging or master will actually result in a site that at least compiles 😛

Let’s take a look at the code:

name: Build

# Controls when the action will run. Triggers the workflow on push or pull request
# events but only for the master branch
on:
  pull_request:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  jekyll:
    runs-on: ubuntu-latest

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

    - name: Build
      uses: SebasWild/jekyll-build-action@445d650279c45fd66c59ddd38620ddb30fceaedf

Just like the last lint action, this one is pretty simple. Originally I was using Jerry van Leeuwen’s jekyll-build-action, but I got some errors downloading the Ruby gems the site requires, so I forked the repository and modified the base docker image it used from the latest version of Jekyll to Jekyll 3.

Deploy Action

Arguable the most complex action (comparatively) for my blog is the deploy action, since it runs through several steps:

  • Run jekyll build
  • SSH into the server & copy the generated files
  • Purge Cloudflare cache

This action took a bit of tweaking to get right, especially narrowing down the parameters required to correctly rsync all the files over to the server. Here’s the code:

name: Deploy

on:
  push:
    branches: [ master ]

jobs:
  jekyll:
    name: Build and deploy Jekyll Site
    runs-on: ubuntu-latest

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

    - name: Build
      uses: SebasWild/jekyll-build-action@445d650279c45fd66c59ddd38620ddb30fceaedf

    - name: Deploy to Server
      uses: easingthemes/ssh-deploy@v2.1.1
      env:
        SSH_PRIVATE_KEY: $
        ARGS: "-rzv --delete --delete-excluded --chmod=g+rwx"
        SOURCE: "./_site/"
        REMOTE_HOST: $
        REMOTE_USER: $
        TARGET: $

    - name: Purge Cloudflare Cache
      uses: jakejarvis/cloudflare-purge-action@v0.3.0
      env:
        CLOUDFLARE_ZONE: $
        CLOUDFLARE_TOKEN: $

Copy phase

The first two workflow steps are the same as for the aforementioned build action - after that it gets more interesting. I use Dragan Filipović’s ssh-deploy which uses NodeJS to ssh and rsync the built jekyll site files to my server. Notable here is the use of secrets - you definitively do not want to expose vital things like SSH keys to your server by having them in the code.

Instead of username/password authentication, it’s generally always recommended to use SSH keys (with a passphrase protected private key) with your server refusing password logons. This way you can only log on if you posses your private key and the server knows your public key. It can also be more convenient than username + password logon on since private keys do not necessarily have to be encrypted with a passphrase, so you could log on without typing anything in at all.

I make use of the above by storing such a private key in a GitHub secret - this SSH key allows the build action to log on to the server using a limited account with access only to the directory where the static files reside. Secrets are maintained on a per repository basis, and are basically key-value pairs. Once set, values cannot be read again, only overwritten. When the workflow is run, this SSH key is filled in to the environment variable SSH_PRIVATE_KEY.

Updating a GitHub secret
Updating a GitHub secret

The ARGS environment variable is the meat of the deployment workflow. It uses rsync, and in ARGS you can define the options to be passed to the rsync command, in this case:

  • -r recurse into directories
  • -z to compress files during the transfer
  • -v for verbose output for easier debugging
  • --delete to remove extraneous files in the destination directory (❗️important❗️)
  • --delete-excluded to also remove excluded files in the destination directory
  • --chmod=g+rwx to change permissions on the transferred files

The --delete options are important, as we want to make sure that the destination directory on our web server contains only the files that jekyll build produced. If the previous version of the site for example contained some content that is now removed, we do not want that to stick around. The --chmod option might vary for your setup - but for me this was necessary as I wanted the group that technical user is part of also to have write access to the site contents.

The rest of the environment variables are easier to understand. SOURCE defines the directory where the files to copy are - in this case the results of a jekyll build are (as is usual) placed in the _site directory. The REMOTE_HOST defines the host where to deploy files to - e.g. swild.dev. REMOTE_USER is the username with which to use SSH, and REMOTE_TARGET is the destination directory to rsync files to.

Cloudflare Purge Cache

This blog is proxied by Cloudflare, simply to speed the site up a little bit. The server hosting this site is also seriously underpowered, with a 2010-era processor (single core!) and a measly 2GB of RAM. While it does not take a lot of resources to host a static website (and it’s not like this blog gets many views…), Cloudflare is perfect for ensuring fast load times even when the user is far away from where you host the site.

To ensure that everyone sees the same site content, we should purge the Cloudflare cache after a successful deployment so that for the first subsequent loads after Cloudflare will fetch the latest files from our origin web server. This can easily be done in the Cloudflare console, but it’s a lot more convenient to do via API 🤙

Cloudflare purge cache button & API

For this I use Jake Jarvis’s cloudflare-purge-action. It’s configuration is simple, although it requires some additional configuration on the Cloudflare side.

CLOUDFLARE_ZONE is the zone ID of your domain. This can be seen on the sidebar of the domain overview page, under the “API” header.

While it’s possible to use your global API key, it’s recommended that you set up a restricted token, one that is locked to just the zones that you specify. You can create such a token by navigating to your Cloudflare profile then navigating to the “API Tokens” and tapping “Create Token”. Configuration will vary for you, but generally you want to do something like the following:

Screenshot of how to create a API token in the Cloudflare control panel

Note that the API token only has permissions to clear the cache for a very specific zone. That way if the key was compromised, all an attacker could do with the key is to clear the cache 😀

Results

After a not-so-insignificant time debugging permissions issues with the deployment workflow, I now have a pretty solid CI/CD pipeline. The checks & actions implemented above allow me to work on the site from anywhere and any device (barring local preview, which requires Docker), and a simple push to master triggers a release deployment. Pretty cool, right? 👍

Screenshot of successful GitHub Actions runs