the avatar image of Benjamin Bouvier

Publish a Zola blog with Gitlab CI, real fast

The two or three regular visitors of this blog (hi friends!) might have noticed I’ve changed the design, as of this year. I’ve actually moved from using the Pelican static blog generator to using Zola. This article shows the continuous integration I’ve set up to automatically build and push content to my server, Real Fast™.

Was there anything wrong with Pelican? Not really. It has served me well over the years, and its naming coming from an anagram of calepin, French for a “small notebook”, is still just genius. Installing on a new machine was a bit of a pain as I needed to recall which Python / virtualenv / etc. commands were required to install dependencies, but over the years I’ve made a Makefile to simplify that. And it didn’t happen that often though; at most I’d run such a command once every two years or so (which is, arguably, already a lot if that’s the frequency at which one switches machines).

I mostly moved to Zola because I’m a zealot / member of the Rust evangelism strike force on my spare time, and want to support tools created with Rust, which are usually blazing 😎 fast 🚀 [1], in addition to being super safe. And, having a statically linked binary is super convenient, and notably super nice for continuous integration and deployment purposes. Indeed, it’s trivial to cache a single binary in CI, and not have to worry about installing/caching dependencies and so on. In addition to that, Zola supports front-matter annotations with TOML or YAML, so it offered a nice migration path from Pelican, who uses YAML for frontmatters.

I’ve been using Gitlab, notably the Framagit instance hosted by the good fellows at Framasoft (give them money to support them!), for the repository hosting the sources of my blog. As such, I’ve wanted to be able to push to the repository, and have the CI build and publish to my website.

Now, let me explain how I’ve did it. These instructions are valid for GitLab Community Edition v17.5.1 ; some things may change in newer versions, so I can’t guarantee they’ll work forever. There’s a first stage that will build the public website using a cached Zola binary, if possible, or grab it from the Github releases website otherwise. The second stage[2] will upload it to the server, using rsync. I’d recommend creating a new user just for this task, with limited SSH access to a single directory, that is, where the generated HTML will live. With caching, each stage takes at most 10 seconds to run, which I find… acceptable 😁.

Here’s the .gitlab-ci.yml file I’ve checked in, heavily commented for your (and my future self’s) best understanding:

default:
  image: debian:stable-slim

variables:
  # The runner will be able to pull your Zola theme when the strategy is
  # set to "recursive".
  GIT_SUBMODULE_STRATEGY: "recursive"

  # If you don't set a version here, your site will be built with the latest
  # version of Zola available in GitHub releases.
  # Use the semver (x.y.z) format to specify a version. For example: "0.17.2" or "0.18.0".
  ZOLA_VERSION:
    description: "The version of Zola used to build the site."
    value: "0.19.1"

build:
  stage: build

  # Cache the Zola binary based on its version, to avoid conflicts between different versions.
  cache:
    key: $ZOLA_VERSION
    paths:
     # $CI_PROJECT_DIR is the current working directory in subsequent steps.
      - "$CI_PROJECT_DIR/zola"

  script:
    - |
      if [ ! -e "$CI_PROJECT_DIR/zola" ]; then
        echo "Downloading Zola…"

        # Download enough to use `wget`.
        apt-get update --assume-yes && apt-get install --assume-yes --no-install-recommends wget ca-certificates

        if [ $ZOLA_VERSION ]; then
          zola_url="https://github.com/getzola/zola/releases/download/v$ZOLA_VERSION/zola-v$ZOLA_VERSION-x86_64-unknown-linux-gnu.tar.gz"
          if ! wget --quiet --spider $zola_url; then
            echo "A Zola release with the specified version could not be found.";
            exit 1;
          fi
        else
          github_api_url="https://api.github.com/repos/getzola/zola/releases/latest"
          zola_url=$(
            wget --output-document - $github_api_url |
            grep "browser_download_url.*linux-gnu.tar.gz" |
            cut --delimiter : --fields 2,3 |
            tr --delete "\" "
          )
        fi

        wget $zola_url
        tar -xzf *.tar.gz
      else
        echo "Reusing cached Zola…"
      fi
    - |
      $CI_PROJECT_DIR/zola build

  # The built artifacts will be put in the `public/` directory, and reused during the next stage.
  artifacts:
    paths:
      - public/
    expire_in: 1 day

deploy:
  stage: deploy
  only:
  - main
  dependencies:
  - build
  script:
  # Install rsync and ssh, if needs be.
  - apt-get update -qq && apt-get install -y -qq rsync
  - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
  - eval $(ssh-agent -s)
  # Set SSH private key, and define the right permissions to not trigger security errors.
  - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
  - mkdir -p ~/.ssh
  - chmod 700 ~/.ssh
  # Set SSH known hosts, and define the right permissions to not trigger security errors.
  - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
  - chmod 644 ~/.ssh/known_hosts
  # Run rsync.
  # Note: -avzh = append / verbose / compress / human-readable
  - rsync -avzh --delete public/* -e "ssh -p $SSH_PORT" $SSH_USERNAME@$SSH_HOST:$SSH_TARGET_DIR

After you’ve set this up, you need to fill some secrets on your CI, by going to Settings, then CI/CD, then fill all the following variables under Variables. Make sure to create them as Masked variables, if not Masked and hidden, unless you’d like the SSH private key to leak 🤡.

Hope this was useful!


  1. Don’t pay attention to the emojis, they’re a meme at this point.

  2. Using two stages is inherited from my previous setup using Pelican, and might be overkill since the each stage takes at most 10 seconds to run, with a hot cache.