Building a Hugo site with Nix

How I learned to build up a Hugo site using Nix.

So when the time came to rebuild this blog with Hugo, I knew it was probably a good excuse to learn how to use Nix for more general-purpose tasks, like generating a static site with a generator. There were some bumps along the way, but for the most part, having my builds be declarative & reproducible was a good idea, I think.

It all starts out with a Hugo site. If you’ve not got one, they have a great guide for that. Once you have that, you can move on to configuring a Nix flake. All I did to start with (while I figured my way around the Hugo CLI) was set up a dev shell.

  inputs.nixpkgs.url = "github:nixos/nixpkgs/release-23.11";

  outputs = inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      systems =;

      perSystem = { self', pkgs, ... }:
          inherit (pkgs) just hugo jq;
          devShells.default = pkgs.mkShell {
            buildInputs = [ just jq hugo ];

This let me run nix develop to drop into a pre-configured shell with just, jq, and hugo installed. Ignoring the other two packages, we’re gonna be using hugo here to quickly test out our site.

$ hugo serve
Web Server is available at http://localhost:1313/ (bind address
Press Ctrl+C to stop

Cool, so our site works. Now, what I could do is just blindly use GitHub’s template for building sites, but being the tinkerer I am, that wasn’t the best I could do. So what I did was dig into the way that Nixpkgs’s runCommand util worked. It seemed to do what I wanted, it lets you run a series of commands and populate a path at the variable $out with a file or directory to be persisted in the Nix store. So I set about doing that.

I knew that Hugo sets up some in-situ directories to track build state (things like lockfiles, pre-compiled static assets, etc.), so I was going to need a temporary working directory to run all this in. I also knew that I would need to probably call it with the --minify flag to be relatively performant.

All said, here’s what I landed on, putting it in my perSystem block in my flake:

{ pkgs, ... }: {
    packages = {
      default = pkgs.runCommand "dist" {
        src = ./.;
        buildInputs = [ hugo ];
      } ''
        work=$(mktemp -d)
        cp -r $src/* $work
        (cd $work && hugo --minify)
        cp -r $work/public $out
        rm -rf $work

So what we’ve got here is runCommand, creating an output in the store called dist, taking the current directory (i.e. where both my flake and Hugo site root are) as a source input, and configuring hugo to be available in the build’s PATH.

Then, we’re giving it a multi-line string which is pretty much equivalent to a bash script in this instance. We create a temporary working directory (which we store the path to in a variable called work), make a copy of our sources there, and then run hugo --minify from that folder to build our site fully. With that done, we can finally copy the contents of the public/ directory to the $out path in the Nix store.

All this comes together to give us a path on the nix store (/nix/store/<hash>-dist/) with our fully-built and ready-to-deploy website.

Nice. So we build this with nix build .#default, and we get the contents of our would-be public/ dir in a symlink to the Nix store path called result.

Note: If you want to run this with symlinked themes in your themes/ dir like me, you need to build .?submodules=1#default, not just .#default. This is because Nix ignores submodules in Flakes for some reason unless you explicitly tell it to use them.

$ ls -lah result
Permissions Size User Date Modified Name
.r--r--r--  3.0k root  1 Jan  1970 404.html
.r--r--r--   65k root  1 Jan  1970 android-chrome-192x192.png
.r--r--r--  336k root  1 Jan  1970 android-chrome-512x512.png
.r--r--r--   59k root  1 Jan  1970 apple-touch-icon.png
dr-xr-xr-x     - root  1 Jan  1970 categories
dr-xr-xr-x     - root  1 Jan  1970 css
.r--r--r--   987 root  1 Jan  1970 favicon-16x16.png
.r--r--r--  3.0k root  1 Jan  1970 favicon-32x32.png
.r--r--r--   15k root  1 Jan  1970 favicon.ico
dr-xr-xr-x     - root  1 Jan  1970 images
.r--r--r--  3.9k root  1 Jan  1970 index.html
.r--r--r--  1.9k root  1 Jan  1970  󰗀 index.xml
dr-xr-xr-x     - root  1 Jan  1970 posts
.r--r--r--   672 root  1 Jan  1970  󰗀 sitemap.xml
dr-xr-xr-x     - root  1 Jan  1970 tags

(I’m using eza as an ls replacement, hence the weird formatting)

Cool. So now, I can run a Nix build in GitHub actions and zip up the contents of public using their actions/upload-pages-artifact@v2 action, and deploy it to Pages like it was any other tool :)

All in all, I’m not sure using Nix for this provides much raw benefit over just using a normal CD pipeline to do this (i.e. using their template), but it was fun to set up and simplifies being able to run this on any machine I want to work on it from.