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 thebuiltins.fetchGit
function we’re calling. LikefetchTarball
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 asfetchFromGitHub
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:
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.