Deployment Updates: Nix
I’ve updated the build process for my site to use Nix.
- If I stop working on it for a year, I want it to work still when I come back to it.
- Faster build times because of caching
- I wanted to learn more about Nix
I originally did this the “regular” Nix way, but it turns out this is kind of the “old” way? Apparently the more popular thing to use recently is “flakes” (which I have seen mentioned in passing a couple times prior). From what I’ve picked up on, this takes away some of the common impure footguns that usually come with Nix, the biggest of which is adding a lockfile for every dependency.
So of course I immediately reimplemented it as a flake.
I found this blog post which helped me
get most of the way there, and I only had to learn a little bit more about how
flakes work to finish (because I also need to install PostCSS via pnpm). I think
I do like the flake approach a little bit better. For one, I can just stick
everything in one file instead of having separate build.nix, shell.nix,
default.nix and whatever else files. I think it’s also provides a bit more
standardization, which felt like one of the most confusing things about Nix when
I first started learning about it.
I found this documentation helpful for setting up pnpm: https://nixos.org/manual/nixpkgs/stable/#javascript-pnpm
Here’s my flake.nix as of writing this:
{
description = "caleb's website";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac";
utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, utils }:
utils.lib.eachDefaultSystem (system:
let pkgs = import nixpkgs { inherit system; }; in
{
packages = rec {
calebsharp-dev = pkgs.stdenv.mkDerivation (finalAttrs: {
name = "calebsharp.dev";
src = self;
nativeBuildInputs = with pkgs; [
git
hugo
pnpmConfigHook
pnpm_10
nodejs_24
];
pnpmDeps = with pkgs; fetchPnpmDeps {
pname = finalAttrs.name;
src = finalAttrs.src;
fetcherVersion = 3;
pnpm = pnpm_10;
hash = "sha256-k/2/Yjcs5h6P2Z6UMzkhMMDHXhTUPlMvMWtorhLLM94=";
};
buildPhase = ''
hugo --minify
'';
installPhase = "cp -r public $out";
});
default = calebsharp-dev;
};
apps = rec {
build = utils.lib.mkApp { drv = pkgs.hugo; };
serve = utils.lib.mkApp {
drv = pkgs.writeShellScriptBin "hugo-serve" ''
${pkgs.hugo}/bin/hugo server -D
'';
};
newcontent = utils.lib.mkApp {
drv = pkgs.writeShellScriptBin "new-post" ''
${pkgs.hugo}/bin/hugo new content "$1"
'';
};
default = serve;
};
devShells.default = with pkgs; mkShellNoCC {
nativeBuildInputs = [hugo go rsync openssh pnpm_10];
};
}
);
}
I’m sure there’s improvements to make and I know there’s still lots to learn. I’m also starting to pick up on the idea that there’s probably many different ways of accomplishing the same thing, which tends to make learning a tad bit slower.
Local dev is super easy, since I can just run nix run (which is an alias of
nix run .#serve) and automatically bring along the dependencies I need (just
hugo in this case).
I also have noticed some improvements in CI performance. I have my self-hosted
Forgejo runner, which is great, but the runner image I use doesn’t include all
the tools I need, so I’ve been running apt from within the runner to add
dependencies I need. I never love doing this because it’s both slower and less
reproducible. I considered building my own custom runner image with the tools I
needed, but that’s an extra maintenance burden that I don’t want, and it doesn’t
help me if I need different versions of tools between projects. Nix has
basically solved all these problems, because it’s fully deterministic, and I can
cache the entire Nix store folder to speed up subsequent builds if dependencies
don’t change. I don’t know if this is totally the right solution (I think a
big problem is the size of the folder can grow indefinitely, so I’ll need some
kind of cron job to periodically prune it), but it seems to be working.
My new deployment workflow:
name: Build and Deploy Website
on:
push:
branches: ["main"]
jobs:
build:
# A custom runner image that includes basically just Nix and node
runs-on: nix-latest
container:
volumes:
- nix-store:/nix
- nix-cache:/root/.cache/nix
defaults:
run:
# Run all commands in the same dev shell environment I run locally. Tools have the _exact_ same version in CI, which is pretty cool
shell: nix develop --command bash -c "{0}"
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Add SSH key
env:
SSH_AUTH_SOCK: /tmp/ssh_agent.sock
run: |
mkdir -p /root/.ssh
which ssh-keyscan
ssh-keyscan x.x.x.x >> /root/.ssh/known_hosts
echo "${{ secrets.SSH_KEY }}" > /root/.ssh/github_actions
chmod 600 /root/.ssh/github_actions
ssh-agent -a $SSH_AUTH_SOCK > /dev/null
ssh-add /root/.ssh/github_actions
- name: nix build
run: nix build
- name: Deploy
env:
SSH_AUTH_SOCK: /tmp/ssh_agent.sock
run: |
# Just need to copy hugo build artifacts to deploy
rsync -avz --delete result/ caleb@x.x.x.x:/srv/calebsharp.dev