Deploying Hugo with GitHub Actions

 ยท 8 min
Pavlo on Pixabay

Almost three and a half years ago, I moved this blog from GitHub pages to my own little machine in the ether. The deployment process was based on Docker and a single Git hook, as I discussed in an earlier post. To simplify my setup as much as possible, I revamped the process to use GitHub Actions with a private repository instead.

When I was writing the previous post, I was still using Jekyll, which I replaced with Hugo in the meantime. The general approach to how I deployed them didn’t change, though.

This article won’t be an in-depth explanation or guide on how to use GitHub Actions. Instead, it’s supposed to give you a taste of the action and encourage you to try out GitHub Actions yourself.


What are Github Actions?

GitHub describes them as “a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline.” In layman’s terms, it’s a way to run certain actions triggered by different events from a GitHub repository, like on push, pull requests, etc. Such actions are combined into workflows with the help of YAML files, which are then run in Docker containers.

How much storage and runtime minutes you get depends on the type of your GitHub account. Free accounts get 500MB of storage and can run action for up to 2.000 minutes. Well, at least in Linux containers. If you want to run on another OS, like building an Xcode project on macOS, there are multipliers to be considered: Windows is 2x and macOS is 10x.

The free-tier limits also apply to free accounts. For all the details, check out the pricing page.

Time to get right into the action(s).

Designing a Publish GitHub Workflow

Like some of the other GitHub features, e.g., PR and issue templates, workflows reside in the .github of a repository in the subfolder workflows. The actual filename doesn’t have much relevance, but let’s call it publish.yml.

The Basic Structure

Here’s the basic structure of GitHub Action workflow file:

yaml
name: Publish Blog

on: push

jobs:
  deploy:

    runs-on: ubuntu-22.04

    steps:
      - ...

The keys are quite self-explanatory, but let’s go over them:

name:
The name of your workflow
on:
One or more events that trigger your workflow. You’re not limited to Git events, though, as workflows can also run on events like “issue created” and others. For this blog, however, only push is required. You can check out all the possible events in the documentation.
jobs:
One or more jobs that will run in Docker containers. In the example, the name of the job is publish. A job has a series of steps that are either a pre-defined action, or you can run commands and shell scripts directly in the container.

Defining Job Steps

To design the pipeline requires you to establish the required steps first. Remember, GitHub Actions start from a simple Docker container with a base image, so you need to set up the environment, too:

  • Checkout the repository
  • Install the correct Hugo version
  • Build the blog
  • Deploy to the remote server

As GitHub Action can run arbitrary commands and scripts, you could create a shell script doing all these things and just run it. But where’s the fun in that?

GitHub Actions

In essence, GitHub Actions are tasks written running in Node.js with access to a lot of GitHub features. Actions allow a workflow to concentrate what it’s supposed to do and not how to do it.

For example, setting up Hugo on a base Docker container would require downloading and installing the binary. Should you use the package provided by the OS, or download a specific version? Do I need Hugo Extended? Is curl or wget available? …

The peaceiris/actions-hugo@v2 action simplifies such decisions into a single step:

yaml
- name: Setup Hugo
  uses: peaceiris/actions-hugo@v2
  with:
    hugo-version: '${{ env.HUGO_VERSION }}'
    extended: true

Let’s go over the different keys:

name:
Identifies the step so you can find it easily later in the Web UI
uses:
Which action the step uses. As you see shortly, this isn’t necessarily required.
with:
Anything under this key is action-specific configuration. In this case, the Hugo version is read from an environment variable, and we need the extended version of Hugo.

Looking back at the broadly defined steps from the previous section, these GitHub Actions emerge:

Checkout the repository

actions/checkout@v3

Install Hugo

Build Blog

  • Run hugo --minify with the right timezone (no action required).

Deploy to Remote Server

The manual step can be done with actions, too, but choosing the right action revealed a problem: which to choose?

Choosing Actions

One problem I’ve encountered each time I dabbled into GitHub Actions was choosing the right action for a task. GitHub itself provides a lot of standard tasks, like checking out the repo, caching content, etc. Still, other tasks could be done by multiple actions, so which should you choose?

My approach is simple, as I check out the underlying repository of the action. The primary factors for my decision are how active it is and if it fulfills all my needs. The other issue might missing configuration options for certain edge cases, or it just won’t do the job the way I like it.

Thankfully, you can easily run your own scripts or command in a workflow step.

Running your own commands is done with the run: key:

yaml
- name: Build Blog
  env:
    TZ: 'Europe/Berlin'
  run: hugo --minify

If an action doesn’t do what you want, or you can’t get it to work even if it’s supposed to work, running commands directly might be a nice band-aid until you get it to work.

Handling Secrets

No deployment pipeline can work without secrets. Even if I’d deploy the blog to GitHub pages, it would require a GitHub API token to commit the generated content to the correct repository. That’s why GitHub can manage secrets (and variables) for you.

In your repository’s settings, there’s a “Secrets and variables” area with “Actions”. Here, you can create new secrets with a name to be referenced in your workflow.

Be aware that secrets can’t be displayed once set!
You can only update them.

For deploying the blog, an SSH key is required, so I copied a newly created private key as a secret called DEPLOY_KEY. Then, I can use it in my workflow via the templating function ${{ ... }} and the secrets variable:

yaml
- name: Install SSH Key
  uses: shimataro/ssh-key-action@v2
  with:
    key: ${{ secrets.DEPLOY_KEY }} 
    known_hosts: 'placeholder' # to make it work

Now we got all the tools necessary to build and deploy the blog.

Running Workflows Locally

Testing your workflows only by pushing to GitHub can be cumbersome and eats up your free runtime minutes.

As GitHub Actions is just a Docker-based workflow configured by YAML, you can run most of the actions locally in Docker, too. The act project takes a workflow file and runs it in your local Docker.

There are certain caveats, as it doesn’t replicate the GitHub environment perfectly. However, to get a pipeline up and running for the first time it’s a great addition to your tool belt.

The Complete Workflow

This is the current version of the workflow:

yaml
name: Publish Blog

on: push

jobs:
  deploy:

    runs-on: ubuntu-22.04

    steps:

      # CHEKCOUT REPOSITORY

      - name: Git checkout
        uses: actions/checkout@v3


      # SETUP HUGO

      # Read environemnt variable for subsequent actions.
      # See: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-environment-variable 
      - name: Read .env
        run: |
          . ./.env
          echo "HUGO_VERSION=${HUGO_VERSION}" >> $GITHUB_ENV          

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: '${{ env.HUGO_VERSION }}'
          extended: true

      - name: Cache Hugo
        uses: actions/cache@v3
        with:
          path: /tmp/hugo_cache
          key: ${{ runner.os }}-hugomod-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            ${{ runner.os }}-hugomod-            

      - name: Build Blog
        env:
          TZ: 'Europe/Berlin'
        run: hugo --minify


      # DEPLOY TO REMOTE SERVER

      - name: Install SSH Key
        uses: shimataro/ssh-key-action@v2
        with:
          key: ${{ secrets.DEPLOY_KEY }} 
          known_hosts: 'placeholder'

      - name: Adding Known Hosts
        run: ssh-keyscan -H ${{ secrets.DEPLOY_HOST_IP }} >> ~/.ssh/known_hosts

      - name: Deploy with rsync
        run: rsync -avz --delete ./public/ ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST_IP }}:${{ secrets.DEPLOY_PATH }}

Where To Go From Here?

As I said before, this article is supposed to give you a first impression of what GitHub Action can do for you with only a few lines of YAML. If you’re interested in using them yourself, check out the following resources. Especially the documentation is quite extensive and explains all the available options.

Another gold mine of knowledge is this curated list of awesome GitHub Actions:

Also, I recommend checking out your favorite GitHub repositories. Many of them most likely already use GitHub Action in some capacity.