Why and How I Deploy This Blog With GitHub Actions

A look at how (and why) this blog, built with a home-brew static site generator, is deployed using GitHub Actions

This blog is powered by my self-made open source static site generator called MHP. Like any static site generator, it takes source files as input and outputs HTML files which are then uploaded to a server where they are served to the wider world using a plain old web server.

At first the build process was manual. I'd run the generator locally and upload the produced files to my server with rsync. In practice, all it took was to run an NPM script which did all that automatically. For a rarely updated personal blog with no traffic that was fine. I wanted to automate in anyway. The way to do that was to set up a CI (continuous integration) pipeline which would detect whenever I pushed something to the site's git repository, build the site and then deploy it.

Other CI Services

Until now, I've used Travis for my public repositories since I had some previous experience with it. Unfortunately, Travis and most of the other CI providers I looked at were way too expensive for private repositories. The cheapest plan at Travis is US$63 a month, and that's with annual billing. That's around the same what I pay for Adobe Creative Suite and Office 365 combined.

I mean, it's a nice service and all but in no way is it worth as much as Excel, Word, Photoshop, Illustrator, Premiere, After Effects all put together. Plus, their mobile versions. Plus, all the applications in the package that I'm not actually using.

As an alternative, I spent way, way too much time trying to set up AWS CodePipeline because Amazon's on-demand pricing seemed much more reasonable. Thanks to years of experience with AWS, I eventually managed to mostly get it working. Mostly. But in the end, it's just too much of a pain in the ass.

GitHub Actions

I moved my private repositories to GitHub after they enabled unlimited private repositories on the free plan. Well, technically the billing page says the limit is ten thousand but even I don't have that many abandoned side projects. Since the free plan also includes 2000 minutes of Actions execution time per month, Actions certainly started to seem worth considering.

At first, I was put off by the unusual way the whole thing is built. Actions are kind of like scripts, each hosted in a GitHub repository, and can be chained together to create workflows. Workflows are configured with YAML files that you commit into a special directory .github/workflows in your repository.

So, a repository hosted on GitHub can have multiple workflows, each of which can have multiple jobs, which are each composed of multiple actions. The point of jobs is they can run on different operating systems and also run parallel by default, unless you specify dependencies between them.

Breakdown of My Deployment Workflow File

Here's a step-by-step breakdown for the main workflow file for my blog. Each snippet is followed by a description of what it does and the reasoning behind why it does so.

name: master

on:
  push:
    branches:
      - master

jobs:
  build:
    runs-on: ubuntu-latest
  steps:

The top of the workflow file sets the name of the workflow and sets it up to be triggered when commits are pushed to the master branch. I have a similar workflow which deploys to a staging environment when something is pushed to the staging branch.

I've configured the workflow to run on the latest version of Ubuntu. I usually try to be more specific than that with my versions, but this is copy-pasted from an official GitHub Actions example and I'm too lazy to fix it before something breaks following a Ubuntu update.

- uses: actions/checkout@v2

The checkout action by GitHub checks out the git repository to the worker.

- name: Initialize SSH agent
  uses: mtti/ssh-agent@v0.2.0
  with:
    ssh-private-key: |
      ${{ secrets.MHUI_DEPLOY_KEY }}
      ${{ secrets.SERVER_DEPLOY_KEY }}

This is my fork of webfactory/ssh-agent which starts ssh-agent on the worker and adds one or more private SSH keys to it. I'm adding two private keys. One is a deploy key for a private repository which contains some common styles and templates that I want to use across projects, and the other one is for connecting to my web server. Doing this with ssh-agent removes the need for doing anything silly, like replacing repository URLs with regular expressions and such.

I'm using my own fork so I'm not pulling code from a random repository directly into my CI pipeline which has access to my web server. Technically, I'm still sort of doing that with NPM dependencies during the installation step below, but at least there's one less attack surface this way.

The secrets are set in the GitHub repository's settings.

- name: Append known_hosts
  run: cat ./known_hosts >> ~/.ssh/known_hosts

There is a file known_hosts in my repository which contains the SSH key signatures for my web server. This step appends those entries to the known_hosts file on the worker so that the rsync step later doesn't fail with a key validation error.

- name: Initialize Node.js
  uses: actions/setup-node@v1
  with:
    node-version: '12.13.0'

The setup-node action, also by GitHub, sets up a specific version of Node.js on the worker. I've standardized my personal projects to 12.13.0 because that's the newest version supported by AWS Lambda.

- run: npm install
- run: npm run build
- run: npm run rsync:production

These three final steps are pretty standard for any Node.js project. NPM dependencies are installed, the project is built and finally, an NPM script runs rsync to upload the generated static files to the webserver over SSH.

While it would be cleaner to first upload the new files to a new directory on the server and then do an atomic rename or something, I want old scripts and stylesheets with Webpack chunk hashes to remain on the server so any old cached HTML pages can still pull the older styles if they're needed. Using rsync also potentially saves some time by uploading only files that have actually changed.

Things to Improve

One thing I will likely fix sooner or later is that, like I mentioned above, the build step has access to the SSH key I use to deploy the site. Unless I'm mistaken, this could be avoided by breaking the deployment part into a separate job so that the key would not be available during the npm install and npm build commands. That way, I could prevent malicious code in an NPM dependency from stealing my server's SSH key.

Other Uses for GitHub Actions

My main interest in GitHub Actions is using it as a CI pipeline, but there seems to be potential for more than just that.

The list of events that can trigger a workflow is quite extensive and includes not just obvious CI things like push to branch and pull request creation, but also things like when a new comment is added to a PR or issue, or when a card is created, moved, deleted, etc. on a project board.

You can even trigger workflows on a cron-like schedule. For example, I could use this to implement scheduled posts on a static blog like this one by scheduling an action to merge a branch to master at a specific time.

You could use this to create all sorts of custom code review and task management workflows, send Slack notifications and so on. You could probably even create your own in-house replacements for automated dependency update bots like Renovate without any external servers.

Hello. I'm Matti Hiltunen, a Finnish software developer and wannabe game designer. This is my blog about software development, gaming and technology.