My Nix Setup
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. The aliases from my last post 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 solved a lot of my local run issues, run a DB in a container rather than install a DB locally, but didn’t really extend beyond that.
The flake
Everything lives in one flake, and it defines three systems:
mbp, my Apple Silicon Mac, managed by nix-darwin and home-manager.bob, a Linux server, running NixOS.james-linux, a standalone home-manager configuration I drop onto Ubuntu and Debian boxes I don’t own outright.
darwinConfigurations.mbp = ...; # nix-darwin + home-managernixosConfigurations.bob = ...; # NixOShomeConfigurations.james-linux = ...; # home-manager on its ownThey share the same inputs, the same modules, and the same user identity, which is defined once in lib/user.nix and read everywhere else. I track nixpkgs 25.11 for the things I want held stable, and pull nixpkgs-unstable in through an overlay exposed as pkgs.unstable, so I can reach for a newer package without dragging the whole system onto unstable.
A module per tool
The bulk of the config is around forty small modules, one per application. There’s a module for git, one for ssh, one for the shell, and then a long tail of single-tool modules: bat, procs, jj, mise, neovim, starship, atuin, tokei. If you read the aliases post, this is where the other half of that story lives. The tool gets installed and configured in one place, and every machine that imports the module gets the same thing.
Splitting it up this way keeps each file small enough to hold in my head. When bat does something I don’t like, I open modules/bat and that one directory is the entire surface area I have to think about. It’s the same reason I’d break a large program into small pieces; a configuration is a program, it just happens to build a laptop.
Secrets in the Secure Enclave
This is the part I’m most pleased with. My secrets live in an encrypted YAML file that is committed to the repository, in the open. They’re managed with sops-nix and age.
The interesting bit is what’s allowed to decrypt them. The primary recipient is an age key that lives in my Mac’s Secure Enclave, through the age-plugin-se plugin. There is no private key sitting in a file anywhere on the machine; decryption goes through the Enclave and is gated by Touch ID. So the encrypted secrets are safe to commit, because the only thing that can read the primary copy is this specific laptop, with my finger on the sensor.
Laptops get lost and occasionally fall in lakes, so there’s a second recipient: a software age key I keep in 1Password. That’s the backup, and it’s also what the Linux hosts use, since they have no Secure Enclave to lean on. Lose the Mac, pull the backup key out of 1Password onto a new machine, and I can decrypt everything again.
Not every credential goes through sops, though. I split them by how they get used:
- The ones I use interactively, like AWS, the GitHub CLI, and SSH, go through 1Password’s shell plugins and agent. They never touch disk, and each use is a biometric prompt.
- The ones a background service needs, a push-notification token, a couple of bot tokens, some API keys, go through sops-nix, which decrypts them to
0400files at activation.
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, and a single command builds the new configuration and activates it on the remote:
just deploy bobOne wrinkle is worth mentioning. I build those configurations on the remote host rather than locally, with remoteBuild = true. 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
I drive all of this through a justfile so I don’t have to remember the longer Nix incantations:
just run # rebuild this Macjust update # bump all the flake inputsjust deploy bob # push to a Linux hostjust secrets-edit # edit the encrypted secretsA 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.
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.