Search

I build my static Hugo site with Nix

3min read nix automation engineering

I chose Hugo for setting up my static blog. I like using vim, and I want to just use the tools I already use for coding to write markdown blog posts, run git add . && git commit -m "new" && git push origin main and have a new article land on my blog. I want my repo to be my CMS, my declarative configuration of all Hugo parameters and plugins, as well as the declarative configuration of the build and deploy process.

I use Gitlab pages for this blog currently, and that involves a very simple .gitlab-ci.yaml file:

image: nixos/nix:latest

pages:
  script:
    # Build the complete site using nix build (includes Hugo + Pagefind)
    - nix build
    # Copy the result to public/ for GitLab Pages
    - cp -rL result public
  artifacts:
    paths:
      - public
    untracked: false
    when: on_success
    expire_in: 7 days
  rules:
    - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH

What I was doing before

Hugo has a built-in PostCSS pipe (via resources.PostCSS), but it shells out to postcss-cli at build time, expecting it in $PATH. This means Node.js dependencies are a hard requirement during Hugo’s own build step. My postcss.config.js chains postcss-import -> tailwindcss -> autoprefixer, all of which must be resolvable at Hugo build time.

Because I was using a community-maintained image, I would often come back to write a new blog post after a long period of absentia, only to find that the build process was broken because of whatever updated library dependency. This made me spend more time debugging my build process than writing for my blog.

I decided that I wanted a hermetic, deterministic build for my blog, so that even if I build the same git SHA 2 years from now, I will get the same build result.

With Nix

I split the build into 3 hermetic layers:

  1. Hugo Modules
  • a fixed-output derivation (FOD) which can access the network to fetch Go modules, but the output is verified against a constant hash.
hugoModules = pkgs.stdenvNoCC.mkDerivation {
    outputHashMode = "recursive";
    outputHash = hugoModulesHash;  # sha256-AAAAA...
    buildPhase = ''hugo mod vendor'';
    installPhase = ''cp -r _vendor $out'';
};
  1. pnpm dependencies
  • needed for PostCSS, Tailwind, autoprefixer
  • same idea as above, use a FOD, hash it, cache it.
pnpmDeps = pkgs.fetchPnpmDeps {
    hash - "sha256-AAAAA...";
}
  1. The pure website build
  • fully sandboxced derivation, zero network access
  • links pre-fetched Hugo modules and pnpm dependencies
  • runs Hugo (which calls PostCSS.Tailwind via the Node.js modules)
  • runs Pagefind for static site search
website = pkgs.stdenvNoCC.mkDerivation {
  nativeBuildInputs = [ hugo nodejs_22 pnpm pnpmConfigHook pagefind ];
  buildPhase = ''
    cp -r ${hugoModules}/* _vendor/
    hugo --minify
    pagefind --site public
  '';
};

The CI knows nothing about Hugo, Node.js, Go, or Pagefind. It just evaluates the flake. If you add a new build dependency (e.g., a Rust-based image optimizer), you add it to flake.nix and CI picks it up automatically. Single point of declarative configuration. Nice!

An example from our CI job:

Log timestamps in UTC.
Running with gitlab-runner 18.7.0~pre.433.g3a5f2314 (3a5f2314)
  on green-6.saas-linux-small-amd64.runners-manager.gitlab.com/default YKxHNyexq, system ID: s_a201ab37b78a
Preparing the "docker+machine" executor 00:29
Using Docker executor with image nixos/nix:latest ...
Using effective pull policy of [always] for container nixos/nix:latest
Pulling docker image nixos/nix:latest ...
Using docker image sha256:f1248e1d1d0215b677d850b73941e291380d0506fe72a27e437bc6c62b8d1f21 for nixos/nix:latest with digest nixos/nix@sha256:c6ebd12d96b3374ee15e3986c15aa43f5e49310634f17afcaaf4dafe4f6732b2 ...
Preparing environment 00:05
Using effective pull policy of [always] for container sha256:6fb144cbf0924e0f1c5fc7fa5fa785c7f5ee41f5ca8acdc5bec68e8e813fc2ff
Running on runner-ykxhnyexq-project-42882508-concurrent-0 via runner-ykxhnyexq-s-l-s-amd64-1770615319-7b63ec23...
Getting source from Git repository 00:02
Gitaly correlation ID: 668696efacf44f4084b4f807ea2ebac5
Fetching changes with git depth set to 20...
Initialized empty Git repository in /builds/bruvduroiu/website/.git/
Created fresh repository.
Checking out 31df4a8b as detached HEAD (ref is main)...
Updating/initializing submodules recursively with git depth set to 20...
Updated submodules
Configuring submodules to use parent git credentials...
Pulling LFS files...

$ nix build
unpacking 'github:numtide/flake-utils/11707dc2f618dd54ca8739b309ec4fc024de578b?narHash=sha256-l0KFg5HjrsfsO/JpG%2Br7fRrqm12kzFHyUHqHCVpMMbI%3D' into the Git cache...
unpacking 'github:nix-systems/default/da67096a3b9bf56a91d16901293e51ba5b49a27e?narHash=sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768%3D' into the Git cache...
copying path '/nix/store/lv4kcr2y4b9k4ynhkga157507zxf372b-source' from 'https://cache.nixos.org'...
these 3 derivations will be built:
  /nix/store/dlgzwfk1qnqjfc9ph3ysk726cz1b7adl-website-pnpm-deps.drv
  /nix/store/ycr61a9d3s6xlrl0zd7csm0rwx787mh2-hugo-modules-0.1.0.drv
  /nix/store/chcs9hp1zjwa88a2fpjpdki175q7g9fk-website-1.0.0.drv
...
building '/nix/store/dlgzwfk1qnqjfc9ph3ysk726cz1b7adl-website-pnpm-deps.drv'...
building '/nix/store/ycr61a9d3s6xlrl0zd7csm0rwx787mh2-hugo-modules-0.1.0.drv'...
building '/nix/store/chcs9hp1zjwa88a2fpjpdki175q7g9fk-website-1.0.0.drv'...

$ cp -rL result public
Uploading artifacts for successful job 00:07
Uploading artifacts...
public: found 1818 matching artifact files and directories 
Uploading artifacts as "archive" to coordinator... 201 Created  correlation_id=25ce3a43a19f48c285a618c221ede729 id=13034611735 responseStatus=201 Created token=6c_Az5ss6
Cleaning up project directory and file based variables 00:01
Job succeeded
series: snippets