Moving My Mac from Chezmoi to Nix Without Losing the Parts That Write Back
My dotfiles passed every check during a chezmoi to Nix migration until I tried to answer which of four tools had installed the binary on my PATH. With a second Mac coming, “it works on this laptop” was no longer good enough.
Part 1 of Three Machines, One Flake, a migration across a laptop, a home server, and a public VPS.
TL;DR
Migrating macOS dotfiles from chezmoi to Nix worked only after I stopped treating the job as a package-list conversion. I assigned one authoritative owner to each scope: Nix for the machine-global environment, mise for project-local versions, and application-owned files for the six configurations that programs rewrite. Nix generations can roll back evaluated configuration, but they cannot restore mutable files, external installer state, or application data.
Chezmoi applied a home/ tree into $HOME. A Brewfile declared the GUI applications. A 103-line defaults.sh wrote macOS preferences imperatively. Mise and uv tool installed the global command-line tools. A 345-line Makefile glued the layers together with handwritten backup, cleanup, and verification targets.
Each tool did its job. None knew what the others had done.
That left me with a machine I could maintain but not honestly rebuild. The repository declared twelve Python tools while the laptop had fifteen. A run-once shell script had no meaningful diff between intent and reality. Rollback meant hoping the right version still existed under ~/.dotfiles-backup. A second Mac was coming, while the entire design assumed there would only ever be one host.
The move to Nix was not about elegance or eliminating independent actors. It was about assigning one authoritative owner per scope, then coordinating the machine-level choices through evaluated configuration. Generation rollback covers that evaluated state. It does not rewind writable application-owned files, SDKMAN installations, self-updating agents, Homebrew’s own state, Spotlight indexes, or other mutable data outside a generation.
The reusable model is scope and precedence. Machine-global tools come from Nix and are rollback-safe by generation. Project-local runtimes come from mise and take precedence only inside pinned projects. Application-mutable configuration remains writable and version-visible, but a Nix rollback cannot restore its contents. External runtimes and installers keep their own state, so they require separate verification and recovery.
Resolve packages by identity
The usual migration plan starts with a list of installed programs and a search box. I wanted stronger evidence, so I downloaded the nixpkgs package index, 149,888 packages at the time, and resolved the global tools against it.
Prove identity and platform support
For each candidate I checked three things:
- Does the package exist?
- Does
meta.platformsincludeaarch64-darwin? - Does the homepage prove it is the same project rather than a namesake?
The third check mattered more than expected. helm is not the Kubernetes client; that package is kubernetes-helm. protoc comes from protobuf, dust from du-dust, and the AWS CLI from awscli2. Top-level ansible was ansible-core 2.21.3, not the 14.3.1 collection bundle. The name was close enough to produce a green package lookup and different enough to remove the modules my playbooks expected.
Let the gaps shape the boundary
Sixty-five of seventy-two global CLIs were available. The seven gaps determined the architecture more than the sixty-five easy wins.
Three were Python-only. Two were unpackaged. The remaining agent CLIs, claude, codex, and pi, update themselves in place. Software in the read-only Nix store cannot do that without replacing the package derivation.
The tempting compromise was to put the easy tools in Nix and leave the awkward ones in mise. That would have preserved two global managers with overlapping PATH entries and no authoritative inventory.
The stable boundary was scope, not tool type:
- Nix owns the global environment.
- Mise remains installed from nixpkgs but owns only per-project
mise.tomlpins. - The global mise
[tools]list is empty, so its shims cannot shadow Nix everywhere.
That distinction matters because eight repositories pin versions a single global flake should not decide. One project needs Terraform 1.15.8. Another still needs Node 18, which is absent from the current nixpkgs input. XcodeGen is pinned independently. Those are properties of projects, not of the laptop.
Files that write themselves
Home Manager normally links managed files to copies in /nix/store. That is an excellent default until the program reading a file also rewrites it.
| File | Writer |
|---|---|
.zshrc | LM Studio appends a PATH block |
.gitconfig | gh auth writes credential helpers |
.ssh/config | Upsun writes a certificate include; 1Password appends IdentityAgent |
.config/gh/config.yml | gh rewrites it on changes |
.config/nvim/lazy-lock.json | lazy.nvim updates it during plugin sync |
.claude/settings.json | Claude Code updates settings |
A store-backed link makes those files read-only, so ordinary application behaviour becomes a write failure. I used mkOutOfStoreSymlink for the six exceptions. The resulting chain has a deliberate second hop:
~/.zshrc
-> Home Manager wrapper
-> ~/Code/personal/dotfiles/home/.zshrcThat sacrifices some purity. The flake now depends on the repository existing at a known path. In return, an application write lands in a writable file where git status exposes it. That is a better failure mode than chezmoi silently overwriting the mutation on its next apply. Those writes are visible to Git, but they are not covered by generation rollback.
Tracking unstable deliberately
Most nix-darwin walkthroughs recommend a release branch. The installed machine made a different argument. nixpkgs-26.05-darwin carried Helm 3.20.2 while the laptop was already on 4.2.4, and it had neither herdr nor markitdown. Unstable matched 34 of 57 installed versions exactly.
So the primary input tracks unstable. The agent CLIs use a second nixpkgs-agents input that deliberately does not follow the first, allowing those fast-moving packages to advance on their own cadence.
This is not a universal recommendation. It is the result of comparing the release branch against the actual machine rather than accepting “stable” as a synonym for “appropriate.” The cost is more frequent input churn and a larger review burden when the lock file moves.
PATH was wrong in both directions
The most transferable failure took three attempts.
Nix first broke project pins
Mise inserts a project’s tool directories relative to the path array in .zshrc. Putting the Nix profiles first looked authoritative but jumped ahead of every project pin:
$ cd ~/work/aws-mgmt
$ terraform --version
Terraform v1.16.0
# The project pins 1.15.8.System paths first revived stale tools
Removing Nix from that array fixed project pins and allowed the system directories to win everywhere else:
$ python3 -c 'import boto3'
ModuleNotFoundError: No module named 'boto3'
$ command -v python3
/usr/local/bin/python3
$ claude --version
2.1.41
# An old installer shim won.Scope determined the final order
The correct order was between the extremes:
mise project directories
~/.cargo/bin
Nix user and system profiles
Homebrew and system directoriesInside a pinned project, mise wins. Everywhere else, Nix wins before /usr/local/bin. ~/.cargo/bin stays in the front group because mise’s Rust installation is a symlink to rustup. The project’s effective toolchain is whichever rustup toolchain is active.
Nothing in the failed arrangements crashed. They selected valid binaries with the wrong provenance, which is why the errors stayed quiet.
Mechanical renames carried hidden behaviour
The first migration commit moved thirty files and changed zero lines. Chezmoi encodes destinations in filenames, so home/dot_zshrc became home/.zshrc, and home/private_dot_ssh/private_config became home/.ssh/config.
git mv preserved the history. It did not preserve everything chezmoi had been doing.
executable_rtk-claude-hook was stored at mode 644. The filename prefix made it executable during apply. Once renamed normally, the repository mode became the deployed mode and the hook would have stopped firing. The fix belonged in the same commit:
$ git update-index --chmod=+x home/.local/bin/rtk-claude-hookAnother encoded filename was more subtle. home/dot_gitignore had been a template source for ~/.gitignore. Renaming it to home/.gitignore made it an active ignore file governing the home/ tree itself, hiding files containing patterns such as .DS_Store, node_modules, and *.log. It stayed in the repository as home/gitignore, with home.nix mapping it to the dotted destination.
The rename was textually mechanical and behaviourally significant.
Forty-three preferences and two type traps
The old defaults.sh had 43 preference writes. Thirty-two mapped to typed nix-darwin options. Eleven belonged in system.defaults.CustomUserPreferences. That removed the run-once shell script and made the desired values evaluable.
Two details were only obvious after reading the module implementation.
system.keyboard.remapCapsLockToControl does nothing unless enableKeyMapping = true. Nix-darwin emits a warning, but warnings inside a long evaluation are easy to scroll past.
The Dock hot-corner options are typed as positive integers. macOS uses 0 to disable a corner. The enabled corner could use the typed option; the three disabled corners had to use the custom-preferences escape hatch. In a diff that looks inconsistent. In the rendered plist it is exact.
That is why I verified the output rather than the Nix expression:
$ nix eval --raw .#darwinConfigurations.laptop.config.system.defaults.finder.NewWindowTarget
PfHm
$ nix eval --json .#darwinConfigurations.laptop.config.system.defaults.dock.persistent-apps | jq -c '.[0]'
{"tile-data":{"file-data":{"_CFURLString":"/System/Applications/Mail.app","_CFURLStringType":0}}}Both values matched the pre-migration defaults read captures.
The App Store problem was Spotlight
Seven App Store IDs transferred neatly into masApps. The first switch tried to install all seven, including Xcode, even though the applications were already present. It stopped at a password prompt before downloading duplicates.
I initially treated the symptoms as separate brew bundle problems. They had one cause. mas discovers installed applications through Spotlight, and Spotlight’s index was read-only on both / and /System/Volumes/Data. Applications appeared absent, remained listed after deletion, or never appeared at all because the index could not move.
$ sudo mdutil -i on -a
$ sudo mdutil -E -aRepairing the index cleared the symptoms. The workaround designed around the earlier diagnosis was reverted.
Tools that stayed outside
Seven tools did not move cleanly, but “not in nixpkgs” was not one category.
Atuin was already at 18.21 while nixpkgs carried 18.19 because the newer release required Rust 1.98. Downgrading was unsafe: 18.21 had migrated a 33 MB history database that 18.19 refused to open. A small derivation wraps the upstream prebuilt binary, the same artifact mise used, and a drift check announces when nixpkgs catches up so the wrapper can be deleted.
SDKMAN is genuinely outside nixpkgs. It installs with rcupdate=false, because its installer must not append to the repository-backed .zshrc. Nix’s Maven package remains alongside it; its wrapper defers JAVA_HOME to SDKMAN rather than replacing it. Runtime verification showed Maven using Java 25.0.4 from ~/.sdkman/candidates.
The exceptions are explicit, scoped, and removable. None silently competes for global ownership. Their state remains outside Nix generation rollback.
Bootstrapping the first switch
The first switch has a circular dependency: darwin-rebuild is installed by the first successful switch. The bootstrap therefore invokes it directly from the flake once.
Two shell details matter. sudo resets PATH, so the outer shell must resolve the absolute Nix binary before privilege escalation. The Determinate installer is interactive by default, so an unattended bootstrap needs --no-confirm.
The script still had a larger gap: it did not install Homebrew. Nix-darwin declares the bundle but does not install brew; activation prints an error and continues when /opt/homebrew/bin/brew is absent. A genuinely fresh Mac could have ended with “Bootstrap complete” and none of its 21 casks. The final instructions even jumped from step 1 to step 3. The missing number was the evidence.
What broke and what the laptop bought
Three live regressions came from moving tracked files before switching to the generation containing their new targets.
Untracking GitHub CLI configuration left a dangling link, so gh could no longer write. The .ssh/config move also left a dangling link. SSH then fell back to defaults and silently bypassed the 1Password agent. Separately, moving commit-signing configuration into a host include that did not yet exist made Git stop signing three commits because Git ignores a missing include without warning.
The individual fixes were simple, but together they established a deployment rule: when a live system points into a repository, renaming a file is a deployment.
The final shape is 27 tracked files outside home/: shared modules, per-host definitions, common/dev/ops profiles, agent declarations, package locks, drift scripts, bootstrap tests, and one mkHost function producing both Darwin configurations. make switch derives the hostname from scutil --get LocalHostName; a mistyped argument cannot rename a machine and apply the other host’s cask set.
The Makefile shrank from 345 lines to 63, and Brewfile, defaults.sh, and chezmoi disappeared. The configuration now declares the 43 preference writes, reduces Homebrew from three formulae to one, and gives the laptop atomic switches with per-generation rollback.
What a chezmoi to Nix migration cannot roll back
The important result was not fewer tools. It was explicit authority for each scope, evidence for checking provenance, and a clear boundary around what rollback cannot recover.
That boundary becomes even more important when running background jobs on macOS with launchd, where configuration and mutable runtime state can fail on different schedules.
Next: the same flake reaches the home server, where twenty-four faithfully transcribed launchd jobs inherit one dependency nobody wrote down.
FAQ
Why use Nix instead of chezmoi for macOS dotfiles?
Chezmoi managed file placement well, but this machine also had Homebrew, shell defaults, mise, and Python tooling writing overlapping parts of the environment. Nix gave those global concerns one evaluated result and generation-based rollback, while out-of-store links preserved the few files applications needed to rewrite.
Should mise remain after moving global tools to Nix?
Yes, when repositories need their own runtime versions. I kept mise for project-local mise.toml pins and removed its global tool list, so project scope and machine scope no longer compete.
Why use mkOutOfStoreSymlink with Home Manager?
Use it for files that applications legitimately modify, such as .gitconfig or .ssh/config. The tradeoff is dependence on a stable repository path, but writes remain possible and git status makes them visible.
How should Nix and mise be ordered in PATH?
Project-specific mise directories should win inside a pinned project, followed by user bins and Nix profiles, with Homebrew and system directories later. Verify with command -v and the tool’s reported version, because a valid binary from the wrong owner can look healthy.
