I got tired of setting up the same laptop three times

Last month I sat down at the wrong Mac and spent twenty minutes wondering why half my PATH was from 2024. Same person, same tools, slightly different story of how they arrived. The other machine had the new zellij. This one still had the brew formula I thought I uninstalled. Loki was staring at me like I owed him a walk. Fair.

Two Apple Silicon Macs and a Linux host. Goal was boring: open a shell anywhere and get nushell, starship, neovim, the usual CLI pile, and the agent tools I keep reinstalling by muscle memory. What I had was folklore. "On the Mini you run this. On the other Mac, that. On Linux, pray."

So I put the machines in a flake and wrote a CLI so I would stop improvising. That project is nixup. This post is how it holds the fleet together. The next one is what happened when the always-on mini had to answer from outside the house.

On macOS it is nix-darwin plus home-manager. On Linux it is home-manager only. Packages and env hooks live in the flake. App configs live in .dotfiles and land with GNU stow. That split sounds pedantic until home-manager and stow both try to own config.nu. Nix installs the binary. Stow owns the config.

The public tree ships example hosts so CI can nix flake show without my hostname in git. Real devices stay local. That boundary bites you the first time you hang a tunnel UUID on one host and almost push it.

I used to edit host modules by hand. That lasted until the second machine. Now the personal file is nixup.toml, gitignored, copied from an example:

[[hosts]]
id = "my-mac"
match_hostnames = ["my-mac"]
os = "darwin"
flake_attr = "my-mac"

[[hosts]]
id = "my-linux"
match_hostnames = []
os = "linux"
flake_attr = "you@linux"
apply = "home-manager"

nixup hosts sync turns that into hosts/<id>/default.nix and hosts/inventory.nix. flake.nix imports the inventory when it exists, otherwise the example. Day-0 is roughly:

cp nixup.toml.example nixup.toml
# edit hosts
nixup hosts sync --yes
nixup bootstrap --yes
nixup smoke --strict

Bootstrap checks for Nix, applies the host, runs smoke, then reminds me about stow. Apply is day-2. Doctor is "why is this machine weird." When I later needed SSH and a tunnel only on the mini, that same host file was how the feature could opt in without Remote Login on every Darwin box.

Pure flakes only see tracked files. My generated inventory is gitignored, which is correct for privacy and wrong for pure eval. Without force-staging, pure builds fall back to example hosts. CI always uses the example. Local apply uses the real inventory that never leaves the laptop.

I spent longer than I want to admit feeling smart about "everything is in the flake" and then watching pure mode ignore the host I just generated. The fix was design: example inventory in git, personal inventory local, CLI that regenerates from TOML. Tunnel ids and authorized keys want that same pocket. Shared modules stay tracked. Machine gossip stays home.

Nix gets the daily CLIs: nushell, starship, stow, neovim, zellij, atuin, lazygit, rg, fd, eza, uv, bun, zola. cloudflared ended up there too, long before I asked it to babysit a tunnel.

GUI and cask-shaped things go through zerobrew on Mac, Homebrew as fallback while that migration finishes. Ghostty is cask-only on purpose. I will not dual-install a terminal again.

Agent tools are the messy middle. rustup, headroom, claude-code, codex-cli, grok, beads, radicle. Activation is fail-soft so a moved installer URL never tanks the whole switch. Smoke lists required tools. smoke --strict exits 2 if one is missing. That receipt is why I trust apply again, and why agents can live on one always-on box while the other machines stay clients.

Nu scripts for bootstrap and smoke worked until they became the product. I wanted typed config, CI-friendly exit codes, and tests that do not need a full machine switch. Four crates: nixup-core, nixup-ops, nixup-smoke, nixup. Virtual workspace, resolver 3. Tests pin a temp nixup.toml and assert unknown hosts exit 4 and strict smoke exits 2. Same verbs on every box: hosts sync, apply, smoke, doctor.

I still don't love all of it. Fail-soft curl installers inside a declarative system are honest and ugly. Secrets stay outside the flake. Stow still needs a dotfiles clone. Pure flakes stay rude about gitignored hosts, so I regenerate from TOML.

And once every machine finally felt like the same desk, a new itch showed up. The mini stays awake with zellij full of agents. The laptop leaves the house. Same shell, same flake, still no honest way to attach from a café without punching a hole in the router.

That part is next: Cloudflare to the mini, paid for in keystroke lag.

Tools