Skip to main content
Lucy McPhail

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
set -eu

SITE="lucymcphail.com"

while read oldrev newrev ref
do
    if [[ $ref =~ .*/trunk$ ]];
    then
        echo "trunk received. deploying..."
        mkdir -p /tmp/$SITE
        git --work-tree=/tmp/$SITE \
            --git-dir=$HOME/$SITE \
            checkout -f trunk
        nix build /tmp/$SITE -o /var/www/$SITE
    else
        echo "$ref successfully received. doing nothing."
    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.