james@kartar : ~ / blog / my-nix-setup
Overview

My Nix Setup

10 min read

My Nix Setup

I would have written a shorter letter, but I did not have the time. - Blaise Pascal

For years my setup was held together by habit. Aliases in one file, a .zshrc that sourced a couple of others, packages I’d installed by hand months ago and could no longer account for, and a small sinking feeling every time I had to set up a new laptop, even with Apple’s semi-magical migration tool. A while ago I gave up and moved the whole lot into Nix: the packages, the shell, the dotfiles, the macOS defaults, the secrets, all of it.

What I get out of that, and the reason I put up with the rest, is that my machine is now a single declarative description I can point at a fresh laptop to get my environment back. My aliases aren’t typed into a .zshrc any more; they’re declared in Nix, and the .zshrc falls out the other end.

But didn’t you work on Puppet and Docker?

Well yes, but both have their place but, frankly, for laptop management, and perhaps even for dev server management, neither is ideal. Puppet is declarative, and for some years I managed my laptop with it, but it was never easy to manage the details without diving into some complex custom Puppet configuration. If you look around, alternatives to configuration management tools for laptops, MacOS especially, still consistent of very, very long Bash scripts, sometimes wrapped in a shiny interface. Many of these are only marginally idempotent and often don’t allow for a lot of customization and flexibility or scope of configuration beyond MacOS defaults: focus tends to be on hardening, or basic dev environment setup, and shell and app configurations tend to light to non-existent.

Docker solves a lot of my local run issues, run a DB in a container rather than install a DB locally, but its configuration management capabilities don’t really extend beyond that.

The flake

Everything lives in one flake, and it defines three systems: mbp, my Mac, managed by nix-darwin and home-manager; bob, a Linux server running NixOS; and james-linux, a standalone home-manager configuration I drop onto Ubuntu and Debian boxes I don’t own outright.

The Mac is the busiest of the three. Its darwinConfigurations.mbp output stitches together nix-darwin, home-manager, sops-nix, nix-homebrew, and a couple of macOS-specific helpers:

darwinConfigurations.mbp = inputs.nix-darwin.lib.darwinSystem {
system = "aarch64-darwin";
specialArgs = { inherit inputs; };
modules = [
./darwin
inputs.nix-index-database.darwinModules.nix-index
inputs.sops-nix.darwinModules.sops
inputs.mac-app-util.darwinModules.default
inputs.home-manager.darwinModules.home-manager
inputs.nix-homebrew.darwinModules.nix-homebrew
{
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
users.${user.username} = { ... }: {
imports = [
inputs.sops-nix.homeManagerModules.sops
inputs.mac-app-util.homeManagerModules.default
./home
];
};
};
}
];
};

user is read from lib/user.nix, which is the single place anything personal lives:

lib/user.nix
{
name = "James Turnbull";
email = "james@ltl.so";
username = "james";
}

Every other module reaches for that file when it needs a name or an email. Changing an address is now a one-line edit, which is a noticeable improvement on the previous arrangement of grepping six dotfiles trying to find the last stale copy.

I track nixpkgs 25.11 for the things I want held stable, and pull nixpkgs-unstable in through an overlay that exposes it as pkgs.unstable:

nixpkgs.overlays = [
(_final: _prev: {
unstable = inputs.nixpkgs-unstable.legacyPackages."aarch64-darwin";
})
# ... other overlays
];

So I can reach for pkgs.unstable.jujutsu when I want a newer release, without dragging the whole system off the stable channel.

A module per tool

The bulk of the config is around forty small modules under modules/, one per application. They’re composed into the user’s home-manager configuration in home/default.nix, which is mostly just imports:

{...}: {
imports = [
./home.nix
../modules/git
../modules/jj
../modules/ssh
../modules/shell
../modules/neovim
../modules/starship
../modules/atuin
../modules/mise
../modules/ntfy
../modules/op-credentials
];
}

Each module is a small file. Here’s modules/jj/default.nix, lightly trimmed:

{ pkgs, lib, ... }:
let
user = import ../../lib/user.nix;
signingKey = "ssh-ed25519 ...";
opSshSign = "/Applications/1Password.app/Contents/MacOS/op-ssh-sign";
in {
programs.jujutsu = {
enable = true;
package = pkgs.unstable.jujutsu;
settings = {
user = { inherit (user) name email; };
ui = { default-command = "log"; diff-editor = ":builtin"; };
fsmonitor.backend = "watchman";
signing = {
behavior = "own";
backend = "ssh";
key = signingKey;
backends.ssh.program = opSshSign;
};
aliases.tug = ["bookmark" "advance" "--to" "@-"];
};
};
programs.zsh.shellAliases = {
js = "jj status";
jl = "jj log";
jc = "jj commit -m";
jnah = "jj restore";
jhellnah = "jj abandon @";
# ...
};
programs.zsh.initContent = ''
jpush() {
jj commit -m "$1" && jj tug && jj git push
}
# ...
'';
}

Everything jj-related is in that one file: the binary (from unstable), the settings, SSH commit signing through 1Password’s op-ssh-sign, the tug revset alias, the shell aliases, and the jpush function that uses tug to advance my bookmark before pushing. When jj does something I don’t like, I open modules/jj and that one directory is the entire surface area I have to think about.

Secrets in the Secure Enclave

This is the part I’m most pleased with. My secrets live in encrypted YAML files that are committed to the repository, in the open. They’re managed with sops-nix and age.

The architecture has named recipients, a per-host file split, and a recovery path. The .sops.yaml declares the named recipients:

keys:
- &se age1se1qtf4r7... # this Mac's Secure Enclave
- &recovery age1md9xwqap... # software backup, lives in 1Password
- &ai_host age1abc... # the ai Linux host's own key

And a creation rule per host file:

creation_rules:
- path_regex: secrets/mac\.yaml$
key_groups:
- age: [*se, *recovery]
- path_regex: secrets/ai\.yaml$
key_groups:
- age: [*se, *recovery, *ai_host]

Each host has its own file: mac.yaml for the Mac, ai.yaml for the Linux box that runs a couple of background services. A host is only a recipient on the file it needs, so a compromised Linux box reads ai.yaml but not mac.yaml. Blast radius is one file per breach, not the whole vault.

The Secure Enclave key is the Mac’s runtime key and the admin key for editing the encrypted files. age-plugin-se writes an identity file, which still needs to be kept private. What matters is that the identity is bound to this Mac’s Secure Enclave: it isn’t a portable age private key, and using it requires both the Enclave and local authentication. I generated mine with the default biometric-or-passcode access control, so decrypting a secret on this laptop is a Touch ID prompt, or my passcode if there’s no finger handy.

Each Linux host has its own software age key, generated on the host and added to .sops.yaml as a recipient on its file. That key never leaves the host, and never appears in any other file’s recipient list. The Mac and Linux configs wire sops-nix through the same lib/sops-config.nix helper, with a boolean flag that picks between the Secure Enclave identity file and the per-host keys.txt. Same plumbing on both ends, different key on each.

The recovery key is an emergency lever, not a runtime key. Its private half lives in 1Password and is never installed on a running host. It’s a recipient on every file, so it can decrypt anything during recovery, but using it is a manual ritual: fetch it from 1Password into a temporary file, set SOPS_AGE_KEY_FILE, use it for the re-key, delete the file afterwards, and re-key again if there’s any doubt about whether the bytes lingered on disk. So the live trust boundary is the Enclave on this Mac plus each Linux host’s own key; the recovery key only enters the picture when something has gone badly wrong.

Only background-service secrets live in sops. The interactive ones go through 1Password. SSH keys are served by the 1Password agent — SSH_AUTH_SOCK points at its socket, so the private keys never touch disk. AWS and the GitHub CLI use 1Password’s shell plugins. And for the long tail of CLI tokens — Heroku, Fly, Railway, ngrok, Graphite — a small op-cli-env module generates lazy shell wrappers that fetch the token from 1Password only when the command actually runs:

Terminal window
heroku() {
HEROKU_API_KEY="$(op read 'op://Private/heroku-cli-token/credential')" command heroku "$@"
}

The token never sits in an env var between invocations, and each first use per terminal is a biometric prompt (with op caching the session afterwards).

Consuming a secret is short. The ntfy module keeps the token out of the generated client config entirely. The client.yml only carries the default host; a small shell wrapper reads the sops-managed token at runtime and passes it to ntfy through the NTFY_TOKEN environment variable, which ntfy treats as the access-token option:

sops.secrets.ntfy_token = {};
home.file.".config/ntfy/client.yml".text = ''
default-host: ${cfg.defaultServer}
'';
programs.zsh.initContent = ''
ntfy() {
NTFY_TOKEN="$(cat "${config.sops.secrets.ntfy_token.path}")" command ntfy "$@"
}
'';

sops.secrets.ntfy_token = {}; declares that this user wants ntfy_token decrypted at activation. sops-nix lands it in a 0400 file owned by me and hands back the path through config.sops.secrets.ntfy_token.path. The wrapper reads from that path on each invocation, so the token never ends up in the Nix store, never gets written into client.yml, and only exists in plaintext for the lifetime of a single ntfy command.

My rule of thumb: if I’m sitting there when the credential is needed, 1Password asks for my fingerprint. If a background job needs it at three in the morning, it has to be sops, because a cron job can’t present a thumb. Well it probably will be able to be in the future when our AI overlords propose amputating my thumb as the ideal solution to cronjob secrets.

Deploying to the Linux boxes

The Mac I rebuild in place with darwin-rebuild. The Linux hosts I push to with deploy-rs. Each one is a node in the flake:

deploy.nodes.bob = {
hostname = "bob.example.com";
user = "james";
remoteBuild = true;
profiles.home = {
path = inputs.deploy-rs.lib.x86_64-linux.activate.home-manager
homeConfigurations.james-linux;
user = "james";
};
};

A just target hides the actual deploy-rs invocation so I don’t have to remember it:

deploy node="bob":
nix run github:serokell/deploy-rs -- --skip-checks '.#{{node}}'

just deploy bob builds the new home-manager closure on the target box and activates it, with deploy-rs handling the rollback-on-failure dance. The remoteBuild = true is worth mentioning: I’m deploying x86_64 Linux from an ARM Mac, and making the laptop emulate an x86_64 Linux builder to cross-compile a whole system closure is slow and occasionally cursed. Letting each target build its own closure is faster and sidesteps the entire problem.

The day-to-day

The justfile collects the longer Nix incantations behind short names:

run:
sudo darwin-rebuild switch --flake .#mbp --show-trace
update:
nix flake update
sudo darwin-rebuild switch --flake .#mbp --show-trace
secrets-edit file="mac":
nix shell nixpkgs#age-plugin-se -c sops secrets/{{file}}.yaml

just run rebuilds the Mac; just update bumps every flake input and then rebuilds; just deploy bob pushes the latest closure to the Linux box; just secrets-edit opens secrets/mac.yaml in sops (or just secrets-edit ai for the Linux host’s file), which decrypts on the way in and re-encrypts on the way out. A pre-commit hook runs alejandra to format the Nix, and deadnix and statix to catch dead code and the dodgier patterns, so the repository doesn’t quietly rot while I’m not looking.

One quiet upside that took me a while to appreciate is the binary caches. I wire a handful of cachix substituters into lib/nix-config.nix, including the ones for the claude-code and codex-cli flakes and the usual nix-community cache. Most package “builds” turn into downloads of someone else’s already-built closures, so just update rarely makes the fans spin.

Is it worth it?

Mostly, yes. The tradeoff is that the good days and the bad days are both a bit extreme. On a good day I rebuild a machine from scratch in the time it takes to make a coffee, and everything lands exactly where I left it. On a bad day I lose an afternoon to a package that won’t build or an option that quietly changed names between releases, and I briefly question every decision that led me here.

The difference from how I used to manage my machines is that the bad days are now written down. When something breaks, it broke in a file I can read, in a git history I can bisect, rather than in the accreted state of a laptop I’d been poking at by hand for three years. That’s the trade I’ll take.