Deploying this blog on NixOS
published: 8 August 2024, tagged: NixOS
For a while now I’ve been wanting to move this website from a managed service (in my case, sourcehut pages) to a server I run myself, and recently I got around to actually doing it. That said though, I really like the user experience these managed services provide: just git push
your site and it gets automagically built and deployed for you.
I think I’ve managed to recreate that experience in NixOS, and I wanted to write down what I did for anyone else who’s curious.
generating the site
My blog is built with Zola, a lightweight static site generator which comes as a single binary with no dependencies. As a result, it’s already very easy to build reproducibly; no faffing with npm install
, just one zola build
and we’re off to the races.
Still, there are a few reasons I like to build it with a Nix flake. Flakes are an experimental* feature of Nix which provide a standard format for declaring packages whose dependencies are pinned in a lock file. This is great for my site, since it lets me build it on any machine I like knowing that the zola
binary is identical on each. It’s also handy for deployments, since the output of a flake gets added to the Nix store (/nix/store/
) each time it gets built, which means I can old builds are always around if I want to roll back.
*well, 'experimental'
Flakes have actually been around since 2019 and are mostly stable at this point, but there’s some controversy around them that hasn’t fully resolved itself yet. In short, the feature was implemented after the original RFC was closed, which made some people feel like flakes were implemented without enough input from the community.
In the years since, a large ecosystem has sprung up around flakes, and I think it’s fair to say most of the community would like them to be stable. As of last year, there’s a new RFC hoping to stabilize things, but for now flakes are still behind a feature flag.
Here’s the flake.nix
for this site:
{
description = "A flake to generate my personal website";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.system;
in
{
packages.site = pkgs.stdenv.mkDerivation rec {
name = "lucymcphail.com";
src = ./.;
nativeBuildInputs = [ pkgs.zola ];
buildPhase = "zola build";
installPhase = "cp -r public $out";
};
defaultPackage = self.packages.system.site;
devShell = pkgs.mkShell {
packages = [ pkgs.zola ];
};
}
);
}
Running nix build
will build the site with Zola and then copy the output to $out
, which Nix sets to somewhere inside /nix/store
, and creates a symlink to it called result
for convenience.
The other advantage of using flakes this way is that they can include local development shells, so I can preview my site locally without having to install Zola for my whole system. The devShell
output lets you run nix shell
to get a shell with Zola ready to use. Better still, I use nix-direnv to automatically load the development shell when I’m in the flake directory, so I can run zola serve
without having to think about it.
configuring caddy
Now that we have the site content, it’s time to get it up on the server! I decided to try out caddy, and I’m very happy with it; the configuration language is simple, the documentation is good, and most impressive of all, it can automatically obtain and update your TLS certificates! NixOS provides a services.caddy
module so it’s easy to set up. We’ll want to create a www
group so that other users can write to /var/www
, and make sure /var/www
exists with systemd.tmpfiles
(I’m not sure why, but this seems to be a common way to create folders with Nix, even if they’re not temporary). Then, we can enable caddy, tell Nix to add the automatically generated caddy
user to the www
group, and serve the site. We’ll add this to our server’s /etc/nixos/configuration.nix
(or wherever you keep your config):
users.groups.www = {};
systemd.tmpfiles.rules = [
"d /var/www 0775 caddy www"
];
services.caddy = {
enable = true;
group = "www";
extraConfig = ''
lucymcphail.com {
encode zstd gzip
root * /var/www/lucymcphail.com
file_server
}
'';
};
One nixos-rebuild switch
later and once caddy obtains some TLS certificates for us, we have a website! Of course, we could just update the contents of the site with scp
and call it a day, but I think it’s much more fun to do it with git :)
running a git server
Using git over ssh
is actually shockingly easy: just run git push <user>@<host>:/path/to/git/repo
and as long as the repository already exists on that machine, it should just work! There are a few things we can do to make it a bit nicer, though:
users.users.git = {
isSystemUser = true;
group = "www";
home = "/srv/git";
createHome = true;
shell = "pkgs.git/bin/git-shell";
openssh.authorizedKeys.keys = [
# your ssh key here
];
};
So now we have a dedicated git user, complete with its own home directory for us to store our repos in. It’s also using the git shell as its login shell, which allows restricted access to git commands over ssh (see man git-shell
). Now, if we create our repos in /srv/git
, we can access them with git@<host>:<repo-name>
.
It’s a good idea to use bare git repositories here, in case we have multiple users contributing. A bare git repo is the same as a regular one, but without a working tree; that is to say, a bare repo just contains the contents of the .git
folder in a non-bare repo. This is perfect for a server, since otherwise we could end up with the working tree going out of sync and causing problems when we try to push.
bonus: git frontend
As an aside, we can also set up a git frontend so we can view our repositories from the web. I like legit for this, since it’s tiny and lightweight compared to larger software development services like gogs or gitea, and I don’t care about being able to edit files or create accounts. Once again, we can set it up using Nix:
services.legit = {
enable = true;
user = "git";
settings.repo.mainBranch = [ "trunk" "main" ];
settings.repo.scanPath = "/srv/git";
};
We’ll run it under the git user too, so that it can read our repos, and tell it where to read them from. I’m one of those strange people who likes using trunk as a branch name, so I’ll also tell legit to recognise that as a default branch. By default, it’ll serve on 127.0.0.1:5555.
building the site with a hook
Back to the website! We’ll create a new bare repo with git init --bare /srv/git/lucymcphail.com
, and now we can create a hook to build the site whenever new changes are pushed. Git hooks live inside the hooks
folder (.git/hooks
in a non-bare repo), and usually there are a few example scripts in there in a new repo. Of particular interest to us is the post-receive
hook, which runs whenever someone pushes to the server. Git hooks are just shell scripts, so we can build the site from there:
#!/usr/bin/env bash
SITE="lucymcphail.com"
while
do
if ;
then
else
fi
done
The post-receive
hook gets called with a list of refs being passed through stdin, so we can read through them all to check if trunk has been pushed, since we don’t want to deploy the site from a feature branch. If there is an update to trunk, we’ll check out the repository into /tmp/lucymcphail.com
and build it with Nix. Once Nix is done, it’ll store the site somewhere in /nix/store
and symlink /var/www/lucymcphail.com
to it. Finally, caddy will start serving the updated version of the site. The whole deployment happens in just a few seconds. Here’s what that looks like:
$ git push buttercup
Pushing to buttercup:lucymcphail.com
Writing objects: 100% (6/6), 553 bytes | 553.00 KiB/s, done.
Total 6 (delta 5), reused 0 (delta 0), pack-reused 0 (from 0)
remote: trunk received. deploying...
remote: Already on 'trunk'
remote: /tmp/lucymcphail.com ~/lucymcphail.com
remote: this derivation will be built:
remote: /nix/store/xgij95fj95j4v9s1z2pfixajnx1ihrch-lucymcphail.com.drv
remote: building '/nix/store/xgij95fj95j4v9s1z2pfixajnx1ihrch-lucymcphail.com.drv'...
remote: ~/lucymcphail.com
To buttercup:lucymcphail.com
0dffb7f..cdde960 trunk -> trunk
So that’s how this site runs! Updating it is really smooth now, just like github pages et al. but with significantly faster build times. I really like this setup so far, and everything being managed by Nix should mean it’s not too hard to maintain and I can roll it back if something breaks.