SWHarden.com

The personal website of Scott W Harden

Build and Deploy a Hugo Site with GitHub Actions

How I use GitHub Actions to build a static website with Hugo and deploy it using rsync without requiring any third-party dependencies

This article describes how I safely use GitHub Actions to build a static website with Hugo and deploy it using SSH without any third-party dependencies. Code executed in continuous deployment pipelines may have access to secrets (like FTP credentials and SSH keys). Supply-chain attacks are becoming more frequent, including self-sabotage by open-source authors. Without 2FA, the code of well-intentioned maintainers is one stolen password away from becoming malicious. For these reasons I find it imperative to eliminate third-party Actions from my CI/CD pipelines wherever possible.

โš ๏ธ WARNING: Third-party Actions in the GitHub Actions Marketplace may be compromised to run malicious code and leak secrets. There are hundreds of public actions claiming to help with Hugo, SSH, and Rsync execution. I advise avoiding third-party actions in your CI/CD pipeline whenever possible.

This article assumes you have at least some familiarity with GitHub Actions, but if you’re never used them before I recommend taking 5 minutes to work through the Quickstart for GitHub Actions.

Example Workflow

This is my cicd-website.yaml workflow for building a Hugo website and deploying it with SSH. Most people can just copy/paste what they need from here, but the rest of the article will discuss the purpose and rationale for each of these sections in more detail.

name: Website

on:
  workflow_dispatch:
  push:
    branches:
      - main

jobs:
  deploy:
    name: Build and Deploy
    runs-on: ubuntu-latest
    steps:
      - name: ๐Ÿ›’ Checkout
        uses: actions/checkout@v3
      - name: โœจ Setup Hugo
        env:
          HUGO_VERSION: 0.100.1
        run: |
          mkdir ~/hugo
          cd ~/hugo
          curl -L "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-64bit.tar.gz" --output hugo.tar.gz
          tar -xvzf hugo.tar.gz
          sudo mv hugo /usr/local/bin          
      - name: ๐Ÿ› ๏ธ Build
        run: hugo
      - name: ๐Ÿ” Create Key File
        run: install -m 600 -D /dev/null ~/.ssh/id_rsa
      - name: ๐Ÿ”‘ Populate Key
        run: echo "${{ secrets.PRIVATE_SSH_KEY }}" > ~/.ssh/id_rsa
      - name: ๐Ÿš€ Upload
        run: rsync --archive --stats -e 'ssh -p 18765 -o StrictHostKeyChecking=no' public/ swharden.com@ssh.swharden.com:~/www/swharden.com/public_html/

Triggers

The on section determines which triggers will initiate this workflow (building/deploying the site). The following will run the workflow after every push to the GitHub repository. The workflow_dispatch allows the workflow to be triggered manually through the GitHub Actions web interface.

on:
  workflow_dispatch:
  push:

I store my hugo site in the subfolder ./website, so if I wanted to only rebuild/redeploy when the website files are changed (and not other files in the repository) I could add a paths filter. If your repository has multiple branches you likely want a branches filter as well.

on:
  workflow_dispatch:
  push:
    paths:
      - "website/**"
    branches:
      - main

Download Hugo

This step defines the Hugo version I want as a temporary environment variable, downloads latest binary from the Hugo Releases page on GitHub, extracts it, and moves the executable file to the user’s bin folder so it can be subsequently run from any folder.

- name: โœจ Setup Hugo
  env:
    HUGO_VERSION: 0.92.2
  run: |
    mkdir ~/hugo
    cd ~/hugo
    curl -L "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-64bit.tar.gz" --output hugo.tar.gz
    tar -xvzf hugo.tar.gz
    sudo mv hugo /usr/local/bin    

Build the Static Site with Hugo

I store my hugo site in the subfolder ./website, so when I build the site I must define the source folder. Check-out the Hugo build commands page for documentation about all the available options.

- name: ๐Ÿ› ๏ธ Build
  run: hugo --source website --minify

SSH Secrets

This part is likely the most confusing for new users, so I’ll keep it as minimal as possible. Before you start, I recommend you follow your hosting provider’s guide for setting-up SSH. Once you can SSH from your own machine, it will be much easier to set it up in GitHub Actions.

Your Keys

The Host’s Keys

To protect you from leaking your private key to a compromised host, you can retrieve your host’s public key and check against it later to be sure it does not change.

To get keys for your hosts run the following command:

ssh-keyscan example.com

My hosting provider (SiteGround) uses a non-standard SSH port, so I must specify it with:

ssh-keyscan -p 18765 example.com

The host’s public keys will be written to the console as a block of text like this:

# swharden.com:18765 SSH-2.0-OpenSSH
[swharden.com]:18765 ssh-rsa AAAAB3Nza1y...zzGGnVX5/Q==
# swharden.com:18765 SSH-2.0-OpenSSH
# swharden.com:18765 SSH-2.0-OpenSSH
[swharden.com]:18765 ssh-ed25519 AAAAC3Nz...Sy4v4ttQ/x3
# swharden.com:18765 SSH-2.0-OpenSSH
# swharden.com:18765 SSH-2.0-OpenSSH

Store this block of text as a GitHub Encrypted Secret (KNOWN_HOSTS) then load it in your action like this:

- name: ๐Ÿ” Load Host Keys
  run: echo "${{ secrets.KNOWN_HOSTS }}" > ~/.ssh/known_hosts

Loading SSH Secrets in GitHub Actions

These commands will create text files in your .ssh folder containing your private key and the public keys of your host. Later rsync will complain if your private key is in a file with general read/write access, so the install command is used to create an empty file with user-only read/write access (chmod 600), then an echo command is used to populate that file with your private key information.

- name: ๐Ÿ” Create Key File
  run: install -m 600 -D /dev/null ~/.ssh/id_rsa
- name: ๐Ÿ”‘ Populate Key
  run: echo "${{ secrets.PRIVATE_KEY }}" > ~/.ssh/id_rsa

Deploy with Rsync

Rsync is an application for synchronizing files over networks which is available on most Linux distributions. It only sending files with different modification times and file sizes, so it can be used to efficiently deploy changes to very large websites.

Many people are okay with the defaults:

- name: ๐Ÿš€ Deploy
  run: rsync --archive public/ username@example.com:~/www/

I use additional arguments (see rsync documentation) to:

- name: ๐Ÿš€ Deploy
  run: rsync --archive --delete --stats -e 'ssh -p 12345' website/public/ ${{ secrets.REMOTE_DEST }}

Clear the Dynamic Cache (SiteGround)

The hosting provider SiteGround has a Dynamic Cache service that automatically caches static content. The dynamic cache can be cleared manually through the web interface, but that is a frustrating and manual process. To clear the dynamic cache programmatically from a GitHub Action, use the following SSH command to engage the site-tools-client application:

- name: ๐Ÿงน Clear Cache
  run: ssh swharden.com@ssh.swharden.com -p 18765 "site-tools-client domain update id=1 flush_cache=1"

Conclusions

That’s a lot to figure-out and set-up the first time, but once you have your SSH keys ready and some YAML you can copy/paste across multiple projects it’s not that bad.

I find rsync to be extremely fast compared to something like FTP run in GitHub Actions, and I’m very satisfied that I can achieve all these steps using Linux console commands and not depending on any other Actions.

Resources