Auto-Deploy Hugo from Github

GitHub is pretty good with out-of-box options for auto-deploying Hugo or Jekyll sites to GitHub Pages. But say you have your own Linode (or any other) web server - you can still take advantage of GitHub Actions which is a surprisingly versatile piece of CI/CD automation to rival anything you can do on the big cloud providers.

Pre-requisites

  • You have your Hugo site checked in to a GitHub repository
  • You have a Linode (or other) web server with SSH access
  • You have a good .gitignore setup so that the Hugo build files are not checked in to your repository a good Hugo example of .gitignore here

An example .gitignore that should be checked in to the root of your project:

If you have anything under public or resources/_gen checked in to GitHub, you will get errors when GitHub Actions tries to build your site. To clean up your repository of these, run the following from the root of your project:

git rm -r --cached public # recursively remove public from git tracking
git rm -r --cached resources/_gen # recursively remove resources/_gen from git tracking

Using --cached means it won't touch any of your local files.

Now commit and push all changes.

Step 1: Create an SSH key pair

SSH in to your server and create a new SSH key pair: Leave the passphrase empty when prompted - it is the key that will authenticate.

cd ~/.ssh # make sure it puts the keys in the right place!
ssh-keygen -t ed25519 gh_deploy_key

This will create two files

  • ~/.ssh/gh_deploy_key (private key)
  • ~/.ssh/gh_deploy_key.pub (public key)

Now add the public key to the authorized_keys file:

cat ~/.ssh/gh_deploy_key.pub >> ~/.ssh/authorized_keys

Now we need to grab the private key and add it to GitHub as a secret.

$ cat ~/.ssh/gh_deploy_key
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

Copy everything from -----BEGIN OPENSSH PRIVATE KEY----- to -----END OPENSSH PRIVATE KEY----- including those lines.

If this next step seems counterintuitive,

let me explain …

When deploying FROM GitHub TO your server:

  • The server needs to authenticate requests coming from GitHub Actions
  • GitHub Actions needs to prove its identity to your server
  • This means GitHub Actions needs the private key, and your server needs the public key

It’s similar to how you SSH into any server:

  • Your computer has the private key
  • The server has your public key in authorized_keys
  • You use the private key to prove you’re allowed to connect

So in this deployment scenario:

  • GitHub Actions is like your computer (needs private key)
  • Your server is like… well, the server (needs public key)

That’s why we:

  1. Generate the key pair on the server
  2. Put the public key in the server’s authorized_keys
  3. Give the private key to GitHub (as a secret)

If we did it the other way around (private key on server, public key on GitHub), GitHub Actions wouldn’t be able to authenticate itself to your server - it would be like trying to unlock a door while leaving the key inside the house.

But I digress …

In your GitHub project, in the web interface that is, go to Settings > Secrets and Variables > Actions.

Create a new Repository Secret called SSH_DEPLOY_KEY and paste the private key in to the value field.

yes I know …Yes I know GitHub has a thing called Deploy Keys, they are for granting access to the repository only. If you were to automate this the other way round (a script on the server pulling from GitHub) then you would use the Deploy Keys section.

Step 2: Create a GitHub Actions workflow

From the root of your repository, create a new directory .github/workflows and in there create a new file deploy.yml with the following content:

name: Deploy Hugo site to Linode

on:
  # Runs on pushes targeting the default branch
  push:
    branches: ["master"]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
  group: "linode"
  cancel-in-progress: false

# Default to bash
defaults:
  run:
    shell: bash

jobs:
  # Build job
  build:

    runs-on: ubuntu-latest

    env:
      SERVER_USER: your_username
      SERVER_HOST: your_server_host.com
      DEPLOY_PATH: /var/www/your_website/html/

    steps:

      - name: Install latest Hugo CLI
        run: sudo snap install hugo

      # you may or may not need this depending on your theme, but harmless if not
      - name: Install Dart Sass
        run: sudo snap install dart-sass

      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: recursive # makes sure to get submodules
          lfs: true # Fetches LFS data, if applicable / harmless to leave in if not
          fetch-depth: 1 # no need to fetch the entire history for deployment

      - name: Build with Hugo
        env:
          HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache
          HUGO_ENVIRONMENT: production
        run: |
          hugo --minify 

      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_DEPLOY_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -H tadg.ie >> ~/.ssh/known_hosts

      - name: Deploy to web server
        run: |
          rsync -avz --delete -e "ssh -i ~/.ssh/deploy_key" ./public/ "$SERVER_USER"@$SERVER_HOST:"$DEPLOY_PATH"

      # GitHub kills the connection when we try to clean this up manually, so we
      # have to trust their own cleanup process I guess ... hmph
      - name: Cleanup
        run: rm -rf ~/.ssh/deploy_key

Pay attention to the env: section and adjust as appropriate for your server

Also this example deploys from the master branch when triggered. You can adapt branches: as necessary.

Then add, commit and push to the master (in this case) branch.

In the GitHub web interface, you should see your job already kicking off under Actions.