I’ve updated the build process for my site to use Nix.

  1. If I stop working on it for a year, I want it to work still when I come back to it.
  2. Faster build times because of caching
  3. 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