Deploying Hugo site using NixOS and nginx

In a hurry, or do you prefer seeing the finished result and working your way backwards from there? You can skip to the finished configuration here.

Background

I’ve had my personal site running using the Python web framework Django since 2015. Most of the heavy lifting was done by django-blog-zinnia, a web blog application for Django that provided a lot of tools for authoring and publishing blog articles, such as a WYSIWYG editor and support for comments. I say “provided”, because in the years since, the development of the library languished and eventually stopped, with the last release on PyPI dating to March 10, 2018.

This wasn’t a problem for a long time and went unnoticed until I tried running my site on Python 3.10 on NixOS 22.11, which failed to serve my blog articles at all due to the library lacking support for the newer Python. While it would have been possible to simply deploy this specific Django application under an older Python interpreter (running multiple versions of the same software side-by-side is something NixOS does really well, after all), it did give me a good opportunity/excuse to dive into the world of static site generation, a perfect use case for sites like the one you’re reading right now. Running a dynamic web application on top of a full-featured web framework for what is essentially 99% static content did seem excessive after a while, and at times a small maintenance burden due to the small issues that tended to crop up. After some very brief research, I picked Hugo for no other reason than that it appeared to be the most popular option around.

A week of effort or so later, I had ported almost all of the content from the old Django application to a svelte new Hugo static site. The sole exception was a single POST endpoint that needed to be left around (can you find it?); this was achieved by slimming the Django application down by removing all of the other views and disabling all but the required core middleware and components. Turns out a lot of the included batteries, such as database access, authentication and such could be easily disabled when needed.

Introduction

This article will detail the steps required to serve a Hugo site down from creating the Nix package to actually deploying the site on a running nginx server in a declarative manner, with very little prior experience required with NixOS. That also means being able to add, remove or change bits and pieces of the configuration and being able to deploy the changes without having to worry about the leftover configuration or other state, as is the case with Ansible, Chef and other similar tools.

Manual cleanup and gradually more complicated playbooks become commonplace with such imperative tools over time as the system accumulates cruft: manual edits to configuration files, automatic edits made by package managers over package updates and other changes that often makes deploying on a fresh system a far different experience compared to deploying on a long-lived system. In contrast NixOS aims to deploy the system from a clean slate every time you run “deploy”, going as far as to break several Linux conventions to achieve this (what do you mean there’s no /bin/bash on my freshly installed system?)

I will assume you already have a NixOS system of some sorts up and running, possibly in a virtual machine or other similar environment. If you don’t have one, the official site has an ISO you can run in a VirtualBox or other VM utility of your choice.

We will be using the following directory hierarchy for the different files. Feel free to create a directory for these anywhere you like, such as the home directory.

├── configuration.nix
├── modules
│   └── hugo-site
│       └── default.nix
└── packages
    └── hugo-site
        └── default.nix
About the directory hierarchy

You are free to arrange the files as you wish; adjust the imports accordingly.

This layout is similar to the hierarchy used in the Nixpkgs repository, used for no other reason than that I found it convenient for my own uses.

What about Nix Flakes?

As you may have heard through the grapevine, Nix Flakes is an experimental feature that allows you to more easily package, and conversely import Nix packages and services into your configuration, as well as version lock those imported dependencies in a way similar to package-lock.json or pip freeze in Node and Python respectively. Perhaps most importantly in terms of reproducibility, this improves reproducibility by allowing you to define the exact version of Nixpkgs to use for the configuration in the Flake lock file. This avoids the problem of the same exact NixOS configuration deploying differently on one system because you executed nix-channel --update at a different time on that system. It’s certainly possible to pin your NixOS configuration to a specific version of Nixpkgs without Flakes, but it’s more involved and less ergonomic. NixOS wiki has additional details on this.

However, this example will use the non-Flake version of Nix, as most of the topics covered will relate to system configuration rather than packaging an application. Nix Flakes also build up on functionality provided by Nix the package manager, so much of the what’s covered here will also be applicable in the Flake-verse as well.

Also worth noting that despite Flakes officially being an experimental feature behind a feature flag, the interface has remained stable for a good while and many NixOS users have adopted it for their projects.

Creating a package for the Hugo application

Your Hugo site is hopefully located in a git repository, private or public.

If your git repository is a private one, you will need to have an SSH agent active when deploying, as otherwise the process will either nag for your SSH passphrase constantly or fail to download the repository at all. One solution I’ve found for this is to launch an ephemeral SSH agent that is kept alive for the duration of a single command and killed afterwards. For example, when deploying a new configuration using nixos-rebuild switch, you could instead provide a wrapper for nixos-rebuild in your shell configuration that takes care of setting the SSH agent:

function s-nixos-rebuild() {
    eval $(ssh-agent -s)
    ssh-add ~/.ssh/id_ed25519_deploy
    trap "kill $SSH_AGENT_PID" EXIT

    sudo nixos-rebuild $@

    kill $SSH_AGENT_PID
}

We’ll start by creating the package derivation in packages/hugo-site/default.nix, which describes how to build the Hugo site. You can consider the term “derivation” analogous to “package specification”, which can include terms such as Arch’s PKGBUILD or Fedora’s RPM spec files. Most notably, Nix uses derivations for many additional purposes besides building software, as we’ll see shortly.

{ lib
, stdenv
, pkgs
}:
 
let
  hugoTheme = builtins.fetchTarball {
    name = "hugo-theme-ananke";
    url = "https://github.com/theNewDynamic/gohugo-theme-ananke/archive/a1a99cf12681ad95b006e648a28139e6b9b75f09.tar.gz";
    sha256 = "07y6mr0ybw0cx37lldqi7hqafxghpi623gj8i21w23yg1yhxid8h";
  };
in
stdenv.mkDerivation rec {
  pname = "hugo-site";
  version = "0.1";
 
  src = builtins.fetchGit {
    url = "https://github.com/gohugoio/hugoBasicExample.git";
    ref = "master";
    rev = "00188bc80985f84f6ee85e41f533dd4a19cae169";
  };
 
  nativeBuildInputs = [ pkgs.hugo ];
 
  installPhase = ''
    mkdir -p themes/ananke;
    cp -r ${hugoTheme}/* themes/ananke/;
    hugo -b http://localhost -t ananke -d $out;
  '';
 
  meta = {
    description = "Hugo project for ${pname}";
    homepage = "https://www.test-site.com";
  };
}

Let’s start from the top: the three variables at the top - lib, stdenv and pkgs - are inputs to our derivation; you can think of them as parameters and the remainder as the function body that actually builds the result we want.

What's with the funky commas in the argument list?

The slightly funky style of having each line start with a comma and the name (eg. , stdenv) is not a rule and could easily be the other way around.

This is following the Nixpkgs style, which allows git to more easily grok line changes without getting confused. Note that if we were to add another input at the end we would need to change one line to add a comma, and then add the actual new line. With the style here, we can get away with simply adding the new line.

More importantly, having each parameter on its own line makes it easier to add more complicated parameters with (conditional) defaults. More on that later.

Before the actual function body we declare a variable called hugoTheme containing a path to an extracted tarball; this is downloaded from GitHub using a fetcher, convenient Nix functions that download archives from other services, extract them into an unique directory in the Nix store we can use later. Some of them are documented here. In this case, we download a Hugo theme called ananke that we will use to style our site.

As for the actual function body, we’re calling the mkDerivation function from stdenv. It is both useful in itself for creating “basic” packages, as well as used as a building block for different derivation generation functions. For example, other language-specific derivation generation functions such as buildPythonPackage use stdenv.mkDerivation behind the scenes. Our derivation for the Hugo site is simple enough that we can make do with just mkDerivation.

The installPhase contains the actual build commands that will be executed in the build sandbox. We create a themes directory in the build root to contain the ananke theme and then run the actual hugo command to build the site to $out, the result directory, as you can probably surmise.

The other arguments should hopefully be self-evident and clarified by the documentation. However, there are a few properties I’d like to clarify:

  • src is the underlying source code for the package we’re building. The notable part here is the builtins.fetchGit function we’re calling. Like fetchTarball before, this is a built-in function in Nix package manager that allows you to fetch and extract an archive from a remote resource, in this case a git repository; it also supports private repositories that require SSH authentication. If you’re retrieving source code from a public repository on a service like GitHub or GitLab, I’d suggest using a fetcher such as fetchFromGitHub instead. In short, if your project resided on GitHub, you might use something like this instead:

    { lib
    , stdenv
    , pkgs
    , fetchFromGitHub
    }:
    
    stdenv.mkDerivation rec {
      # ...
    
      src = fetchFromGitHub {
        owner = "github-user";
        repo = "github-repo";
        rev = "master";  # Use git branch or tag here
    
        # SHA256 hash is required for reproducibility. When you try to build
        # the package the hash will cause a failure. You can then copy the correct
        # hash here and build the package again.
        sha256 = "1111111111111111111111111111111111111111111111111111";
      };
    
      # ...
    }
    
  • The rec keyword next to the curly braces allows the description to use self-referencing. A quick example would be the following:

    stdenv.mkDerivation rec {
      pname = "hugo-site";
    
      meta = {
        # 'rec' required so we can reference 'pname'
        description = "Hugo project for ${pname}";
      };
    }
    

    The example here is somewhat contrived and we could do without the rec here. Still, I’ve found it useful to have it “just in case”, as the package definition can be updated to use references and avoid repetition.

Building the Hugo package

You should now have a Nix file in packages/hugo-site/default.nix or similar location. How do we test and build it?

$ nix-build -E "with import <nixpkgs> {}; callPackage ./packages/hugo-site/default.nix {}"
/nix/store/2mssp8ca820ph31h3ikqinwvwm5n1vnx-hugo-site-0.1

That’s quite the mouthful and why is it necessary? Well, in the package definition we just wrote we used a bunch of functions defined in Nixpkgs such as pkgs, stdenv. In fact, the only function that was not defined in Nixpkgs was builtins.fetchGit (assuming you didn’t replace it with fetchFromGitHub or similar fetcher instead). nix-build does not import Nixpkgs by default; instead, we provide a complete Nix expression with the necessary import using the -E parameter and evaluate it (i.e. build it and return the resulting directory).

The command should print the path to the finished package in your local Nix store. Feel free to inspect that it contains the expected files.

Deploying the Hugo site using nginx

Recall that we used the hugo package from pkgs to build the actual Hugo site. How can we make our own package accessible through this parameter, even if our package is not actually part of Nixpkgs?

Overlay is the solution to this problem, as it allows to extend Nixpkgs with our own packages.

This also marks a good place to create the configuration.nix file, which will be the “entry point” to our deployed site. Start with the following template:

{ nixpkgs, ... }:

{
  imports = [
    ./modules/hugo-site/default.nix
  ];

  nixpkgs.overlays = [
    (self: super: rec {
      hugo-site = super.callPackage ./packages/hugo-site {};
    })
  ];
}

And with that we’ve added hugo-site to the list of packages available through pkgs. Of course, we need to write some more configuration that actually uses the package; this will be done in modules/hugo-site/default.nix which we’ve already imported here.

Shouldn't the import be added at the end?

You might have noticed that we import the configuration where we are going to use the hugo-site package before the overlay declaration where we define the hugo-site package.

This is not a problem since the Nix language is purely functional; the expressions are not evaluated in the order they are written; Nix will see what the relationships between the values are and take care of evaluating outermost dependencies first.

Next up is the module that will encapsulate the configuration to deploy the site on an HTTP server. Create modules/hugo-site/default.nix with the following content:

{ config, pkgs, ... }:

let
  webDomain = "test-site.com";
in
{
  services.nginx = {
    enable = true;

    virtualHosts."${webDomain}" = {
      serverAliases = [ "www.${webDomain}" ];

      locations = {
        "/" = {
          alias = "${pkgs.hugo-site}/";
        };
      };
    };
  };
}

This declares a nginx server that runs the generated the Hugo site on test-site.com (feel free to change the domain accordingly). All the options used here correspond pretty closely to their “vanilla” nginx counterparts. Most importantly, all NixOS configuration options such as the ones here are best searched using the search.nixos.org/options site. If you’re working on NixOS configurations, it’s likely the one resource you’ll be using the most.

If you’ve been following along so far, there should be only a few steps before you can deploy the configuration and see it in action. Edit the main NixOS configuration file at /etc/nixos/configuration.nix (which can also be a symlink if you prefer to have your configuration elsewhere for easier editing!). In it, add the following import:

{ config, pkgs, ... }:

{
  imports = [
    ./hardware-configuration.nix

    "/path/to/hugo-site/configuration.nix"
  ];

  # ...
}

After that just run nixos-rebuild switch and you should end up with a HTTP server serving the Hugo site on port 80.

Where's the nginx configuration file?

You might expect the nginx configuration file to be located somewhere under /etc. That’s not the case with NixOS, as the nginx configuration is stored under /nix/store as it’s a result of a derivation, same as the Hugo package we created earlier.

The nginx systemd service still exists as you’d expect, and it contains an absolute path to the configuration file.

$ systemctl cat nginx | grep ExecStart
ExecStart=/nix/store/inn7yf1n8fqrj7z5j9hicn284s3sykcq-nginx-1.24.0/bin/nginx -c '/nix/store/09sqpwb6fren12ydv7qkgr2p2prasr34-nginx.conf'

This might seem confusing at first glance, but turning configuration files into derivations is what allows us to freely remove, change and add any parts of configuration without the risk of old configuration lingering around; each configuration file is essentially created from scratch when we run nixos-rebuild!

Testing the site using curl/wget/etc on the NixOS machine

Are you doing the tutorial on a headless NixOS server or other environment where a web browser is not readily available? Fret not, you can quickly test it using curl, wget, httpie or any HTTP utility of your choice.

Just open a terminal and type nix-shell -p PACKAGE. Tab completion should work, letting you see all the packages available on Nixpkgs.

$ nix-shell -p httpie

[nix-shell:~]$ http GET test-site.com
<html>
...
</html>

This will download the selected package(s) from NixOS’ binary cache and drop you into a shell where you can instantly use the application. Perfect for trying out applications quickly without the need for root access or the commitment to install them permanently.

Parametrizing the web domain

We now have a working Hugo site, but there’s a small issue: I want to deploy it on an actual domain such as https://test-site.com and have the site map and other pages with absolute URLs built with the correct domain. How do we achieve that?

Remember the parameters we have at the start of each Nix file? Let’s update our derivation for the Hugo site to have an optional parameter for the base URL that we can override, which involves two line changes:

{ lib
, stdenv
, pkgs
, baseURL ? "http://localhost"  # Add this
}:

# ...
{
  # ...
  installPhase = ''
    mkdir -p themes/ananke;
    cp -r ${hugoTheme}/* themes/ananke/;
    hugo -b ${baseURL} -t ananke -d $out;  # Change this
  '';
  # ...
}

Changing our nginx configuration is likewise a trivial change; each package provides an override function that we can use to retrieve a version of the package with any of the parameters changed:

{ config, pkgs, ... }:

let
  webDomain = "test-site.com";
in
{
  services.nginx = {
    enable = true;

    virtualHosts."${webDomain}" = {
      serverAliases = [ "www.${webDomain}" ];

      locations = {
        "/" = {
          alias = "${pkgs.hugo-site.override { baseURL = "https://${webDomain}"; }}/";  # Change this
        };
      };
    };
  };
}
The many things you can do by overriding packages

Overriding packages found in Nixpkgs or elsewhere is a powerful feature. Let’s say we want to introduce a tiny change to an existing package; how do we do that?

You can look up packages found in Nixpkgs using the very convenient search utility found at search.nixos.org/packages. In addition to the usual details (name and version), it also provides a link to the exact source file where the package’s derivation and its various parameters and properties are defined.

As an example, here you can find the source code for the FFmpeg derivation. It provides a myriad of different parameters you can use to very easily build different variations of the package. Best of all, we can pass these parameters using the nix-shell -p PACKAGE trick we introduced in the earlier side note, using the same syntax we used in the configuration file earlier. So, to build and “install” a version of FFmpeg without x264 support we can run the following:

$ nix-shell -p 'ffmpeg.override { withX264 = false; }'

[nix-shell:~]$ ffmpeg -version

This command will then take care of retrieving all the necessary the source code and build dependencies, and compiling the package to your liking; or skipping all that if the exact version of the package with your parameters has already been compiled and exists in your Nix store. You can test this by running the command again once you’ve built it once; the new shell with customized ffmpeg should appear almost instantly.

Compare that to a more pedestrian distro such as Arch, Debian or Fedora where you’d have to track down the source package (SRPM, PKGBUILD or what not), install the required dependencies and tools, build the package and then finally install it. Good luck if you also want to install the package alongside the existing stock package.

Parameters are not the only thing you can substitute here; you can also adjust the attributes in the function body itself using overrideAttrs, which has a slightly different syntax since we’re effectively providing a function instead of a set. In effect, this allows us to do more fine-grained changes such as building the package with a different version and source, but otherwise using the same attributes:

$ nix-shell -p 'ffmpeg.overrideAttrs (final: prev: { src = fetchgit { url = "https://git.ffmpeg.org/ffmpeg.git"; rev = "n5.1.2"; sha256 = "sha256-4jcfwIE0/DgP7ibwkrSm/aPiHIMFn34JNcXkCMx4ceI="; }; version = "5.1.2"; })'

[nix-shell:~]$ ffmpeg -version
ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers
built with gcc 12.3.0 (GCC)
...

In a way, NixOS combines elements of both binary distributions such as Debian and source distributions such as Gentoo. Installing common software is done automatically by downloading it from the public binary cache, while customizations are made easy and powerful by essentially providing the distro’s source code with each installation, allowing both small and sweeping changes to be made by customizing this source code on the fly.

You can refer to Nixpkgs documentation for additional details:

https://nixos.org/manual/nixpkgs/stable/#sec-pkg-override

Wrapping up

By now you should hopefully have a working nginx installation serving a Hugo site. If not (or you’re simply in a hurry), you can look up the Nix files below for what you should have ended up with.

Tutorial Nix files

configuration.nix

{ nixpkgs, ... }:

{
  imports = [
    ./modules/hugo-site/default.nix
  ];

  nixpkgs.overlays = [
    (self: super: rec {
      hugo-site = super.callPackage ./packages/hugo-site {};
    })
  ];
}

modules/hugo-site/default.nix

{ config, pkgs, ... }:

let
  webDomain = "test-site.com";
in
{
  services.nginx = {
    enable = true;

    virtualHosts."${webDomain}" = {
      serverAliases = [ "www.${webDomain}" ];

      locations = {
        "/" = {
          alias = "${pkgs.hugo-site.override { baseURL = "https://${webDomain}"; }}/";
        };
      };
    };
  };
}

packages/hugo-site/default.nix

{ lib
, stdenv
, pkgs
, baseURL ? "http://localhost"
}:

let
  hugoTheme = builtins.fetchTarball {
    name = "hugo-theme-ananke";
    url = "https://github.com/theNewDynamic/gohugo-theme-ananke/archive/a1a99cf12681ad95b006e648a28139e6b9b75f09.tar.gz";
    sha256 = "07y6mr0ybw0cx37lldqi7hqafxghpi623gj8i21w23yg1yhxid8h";
  };
in
stdenv.mkDerivation rec {
  pname = "hugo-site";
  version = "0.1";

  src = builtins.fetchGit {
    url = "https://github.com/gohugoio/hugoBasicExample.git";
    ref = "master";
    rev = "00188bc80985f84f6ee85e41f533dd4a19cae169";
  };

  nativeBuildInputs = [ pkgs.hugo ];

  installPhase = ''
    mkdir -p themes/ananke;
    cp -r ${hugoTheme}/* themes/ananke/;
    hugo -b ${baseURL} -t ananke -d $out;
  '';

  meta = {
    description = "Hugo project for ${pname}";
    homepage = "https://www.test-site.com";
  };
}

Also remember to import the configuration.nix file in /etc/nixos/configuration.nix like so:

{ config, pkgs, ... }:

{
  imports = [
    ./hardware-configuration.nix

    "/path/to/hugo-site/configuration.nix"
  ];

  # ...
}

NixOS contains a lot of other goodies, such as allowing you to enable Let’s Encrypt certificate creation for a nginx virtual host with a few configuration options, simplifying server configuration radically. Still, I hope this has given you a good “vertical slice” of putting together a simple NixOS web server and perhaps some inspiration to dig more into the myriad of tools NixOS gives you, as they make short work of common configuration problems once you get past the (admittedly steep) learning curve.