Notfiles: or how I learned to stop worrying and love Nix
tl;dr: Checkout manager, my setup for a fully deterministic MacBook using Nix, nix-darwin, and home-manager.
After losing a MacBook Pro recently, 10 years of hand-written settings I depend on were gone. Backups existed, but all of the little configurations were lost to time. Things like macOS network settings, sleep/wake, time machine backup schedules, GUI applications and their configurations + licenses.
This motivated me to find a way to instantly recreate my MacBook in its entirety in less than 30 minutes without using backups or a self-hosted MDM profile. This includes GUI Apps, licenses, config files, system settings, launchd daemons, fonts and special files, etc.
I decided to embark on a quest to fold my entire machine into a single set of declarative configuration files. I had 173 packages across Homebrew and the App Store, macOS defaults, 196 system fonts, 5 Git identities managed by 1Password and GPG, custom zsh prompt, terminal, and editor config for vim, neovim, helix, and Zed.
Aside from losing a machine, I am regularly configuring new MacBooks for myself and my team. Most companies have a CISO and security policy which mandates external vendors use sanctioned machines (good policy if you ask me, waste of time if you ask me). I do a lot of Technical Due Diligence for M&A, which means time is money (literally). Any time I waste from opening the FedEx box to being maximally productive is an issue.
Nix has been on my radar for a long time. I often see blog posts about it and appreciate the sentiments. It wasnβt until LLMs started getting good that the idea (and time) of reproducing my entire development setup felt possible and worthwhile.
To quote Bill Baker on server management: βcattle, not petsβ. This post is about moving a decadeβs worth of accumulated dotfiles and ad-hoc macOS settings, apps, files, and more into a Nix-based machine definition as well as unlocking new levels of reproducibility.
dotfiles? more like notfiles
My first dotfile commit landed on August 3rd, 2015. It was a ~/.zshrc snippet that ran ls after every cd because I kept getting lost. Over the next ten years I effectively left the files on the machineβs disk. I might have had the idea to back them up to Github, but I certainly wasnβt thinking about making them robust.
Then Anish Athalye made a Python tool called dotbot which read a YAML manifest and symlinked tracked files into $HOME. My memory at the time was that there were 1-2 other tools doing something similar. devContainers were not popular or mature and devenv did not make it onto the scene.
While dotbot and tools like it are excellent itβs a bit of an intermediate tool between a simple Bash script and Ansible. It will place files where they need to go, and run arbitrary bash.
Dotfiles are not full-system configuration, just a few developer tools. It takes more work and effort to configure macOS System Settings, GUI applications, and 1Password. Thereβs only so far it will go before you either end up writing AppleScript to click on buttons or abandoning it. I ended up abandoning it for ~6 years.
Nix at a high level
Iβm not a Nix person truly, but I admire it for being a powerful tool. This is my first foray into it, thereβs a whole ocean out there, my experience is with dotfiles and config.
Nix is just a set of configuration files + a deterministic runtime which produces output. This makes Nix a package manager that treats every package install as a build. Instead of brew install, which mutates /usr/local and hopes for the best, Nix produces a content-addressed artifact under /nix/store/<hash>-<name> and links to it from your PATH.
Two community projects do the work I actually wanted.
nix-darwinextends Nixβs model to macOS system-level state: login shells,launchdagents,defaults writesettings, even Homebrew formulas.- Home Manager extends it to the dotfiles inside
~. Every knob and setting on the machine is exposed as a nix function, and the whole machine becomes one big derivation. You can build it, throw it away, and build it again, and the second build is byte-identical to the first. Really excellent tool if youβre looking for something to cover more than justzshrc.
For the actual Nix install on macOS I use Determinate Nix. The vanilla Nix installer works, but Determinate ships a friendlier daemon with some sensible default configurations and an official nix-darwin.
The full toolchain is Determinate Nix at the bottom, nix-darwin for system-level state, Home Manager for user-level state, and a Nix flake that pins all three to the same release.
Overall structure
manager/
βββ flake.nix # entrypoint
βββ flake.lock
βββ install
βββ bootstrap/install.sh
βββ lib/vars.nix
βββ hosts/ # host-specific configuration
β βββ AVA/default.nix
βββ modules/
β βββ darwin/ # nix-darwin to set macOS settings + packages
β β βββ base.nix
β β βββ packages.nix
β β βββ shell.nix
β β βββ homebrew.nix
β β βββ defaults.nix
β β βββ launchd.nix
β β βββ hosts.nix
β βββ home-manager/ # dotfile, ssh, TTY, script, shell management
β βββ base.nix
β βββ packages.nix
β βββ shell.nix
β βββ shell/{init,env,profile,completion-styles,zsh-options,git-aliases}.zsh
β βββ editors.nix
β βββ ghostty.nix
β βββ git.nix
β βββ gpg.nix
β βββ ssh.nix
β βββ tmux.nix
β βββ ...
βββ scripts/smoke-testaccount.sh
flake.nix is the entry point. It pins inputs, discovers hosts from the hosts/ directory, and assembles a darwinConfiguration per host.
# flake.nix
{
description = "macOS bootstrap with Determinate Nix, nix-darwin, and Home Manager";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-25.11-darwin";
nix-darwin.url = "github:nix-darwin/nix-darwin/nix-darwin-25.11";
home-manager.url = "github:nix-community/home-manager/release-25.11";
determinate.url = "https://flakehub.com/f/DeterminateSystems/determinate/3";
nix-darwin.inputs.nixpkgs.follows = "nixpkgs";
home-manager.inputs.nixpkgs.follows = "nixpkgs";
};
# build everything for this host
outputs = inputs@{ self, nixpkgs, nix-darwin, home-manager, ... }: {
darwinConfigurations = lib.genAttrs hostNames mkDarwinConfiguration;
};
}
Each host directory holds the per-machine facts: hostname, username, home directory, and system architecture.
Anything reusable between hosts like constants (full name, signing key, per-identity table) live in a single lib/vars.nix so I never re-type them.
A closer look at a few modules
Home Manager
The Homebrew and macOS defaults examples are pretty straightforward, but there are few modules that ended up mattering more.
Home Manager manages the config file the tools already read.
For SSH programs.ssh writes a normal ~/.ssh/config, and services.gpg-agent writes a normal gpg-agent.conf. This places files in the appropriate place without SSH or GPG knowing that nix is involved.
# modules/home-manager/gpg.nix (excerpt)
services.gpg-agent = {
enable = true;
enableSshSupport = false; # 1Password is the only SSH agent
defaultCacheTtl = 600;
maxCacheTtl = 7200;
pinentry.package = null; # pinentry-mac comes from the brew bridge
extraConfig = "pinentry-program /opt/homebrew/bin/pinentry-mac";
};
When a tool does not have a matching entry in modules/*, I can place individual files using home.file."<path>".text or .source places an arbitrary file into the home directory ~ and tracks it in the same generation.
I figured out how to keep secrets entirely out of Nix configuration. All files placed under /nix/store are world-readable, so a private key written into a derivation is a key exposed to every process on the machine.
I use 1Password for everything, you should use a password manager as well. I configure SSH to use 1Password as the identity agent at runtime. It works by pointing the binary at the correct socket on device. This keeps public and private keys off disk and puts everything behind biometric authentication.
# modules/home-manager/ssh.nix (excerpt)
let
onePasswordSocket =
"${config.home.homeDirectory}/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock";
identityAgent = ''"${onePasswordSocket}"'';
in {
programs.ssh = {
enable = true;
enableDefaultConfig = false;
matchBlocks."*".extraOptions.IdentityAgent = identityAgent;
};
}The Git Identity Problem
I have a lot of Git identities, some for specific clients, some for personal work, and others for professional work at Sancho Studio.
If you Google this problem youβll get a lot of half-baked answers from 6 years ago on StackOverflow. The most common piece of advice is to open your gitconfig file and add a path-based matching rule under includeIf like soβ¦
# gitego auto-switch rule
[includeIf "gitdir:/Users/CASE/code/professional/keybase/"]
path = /Users/CASE/.gitego/profiles/keybase.gitconfig
The issue with this is that you also have to do something equivalent over in ~/.ssh/config and keep the remote Github/GitLab/BitBucket/ADO domains aligned with the signing identities. Git wants a directory based matching system and SSH wants an identity/remote based matching system. Frustrating to keep in sync and easy to screw up. When it goes wrong, only ssh -v can save you.
I found a solution in the excellent tool called git-ego which manages and switches Git committing identities at runtime based on the CWD. Itβs configured with config.yaml, a per-identity gitconfig fragment, and the includeIf rules that select an identity by directory. It works flawlessly ~ you should be using it outside of Nix.
# modules/home-manager/git.nix (excerpt)
#
# use Nix to define all of my git identities in lib/vars.nix, then map over them
# to generate matching profiles for git-ego to read under
# ~/.gitego/profiles/<identity>.gitconfig
includeIfBlocks = lib.flatten (lib.mapAttrsToList
(id: i: map
(rule: {
condition = "gitdir:${rule}";
path = "${config.home.homeDirectory}/.gitego/profiles/${id}.gitconfig";
})
i.autoRules)
identities);
programs.git.includes = includeIfBlocks;
Now I have two tools working in my favor
- git-ego is going to switch the git and SSH configuration based only on my current working directory
- The SSH agent is going to ask 1Password to use the correct key, which I can see in a 1Password popup that lets me sanity check that the right key is being picked up.
Now, a commit under ~/code/personal/jryio/ is signed by one identity and a commit under ~/code/professional/<client>/ by another.
One Off Scripts
Finally there are activation scripts that cover the cases where a clean option does not exist or does not do what you want. For example, I have my own set of custom fonts I want included on every machine. macOS fontd will not register fonts that are symlinks into the Nix store, so the obvious home.file symlink approach places the files and registers none of them. I used an idempotent shell script that Nix still sequences and tracks: it copies the bytes into ~/Library/Fonts, records a manifest, and restarts fontd only when the manifest changed.
# modules/home-manager/fonts.nix (shape)
home.activation.vendoredFonts =
lib.hm.dag.entryAfter [ "writeBoundary" ] ''
# install -m 0644 each vendored font into ~/Library/Fonts
# track ownership in ~/.local/state/manager/fonts.manifest
# killall -u "$USER" fontd only when the manifest changed
'';
/etc/hosts is the same situation: nix-darwin 25.11 exposes no networking.hosts option on macOS, so a system.activationScripts.postActivation hook writes a delimited managed block from the inventory and strips anything outside it. Declarative does not mean an option exists for everything. It means there is one tracked, idempotent path that produces the state, even when the last step is a shell script.
Homebrew
The smallest win is Homebrew:
# modules/darwin/homebrew.nix
{
homebrew = {
enable = true;
onActivation = {
autoUpdate = false;
upgrade = false;
cleanup = "none";
};
taps = [ "charmbracelet/tap" "oven-sh/bun" "stripe/stripe-cli" ];
brews = [ "agent-browser" "bat" "caddy" "ccusage" "fzf" "ripgrep" ];
casks = [
"1password-cli"
"ghostty"
"gpg-suite"
"timemachineeditor"
"wireshark-app"
];
masApps = {
"1Password for Safari" = 1569813296;
"Things" = 904280696;
"Xcode" = 497799835;
};
};
}
While Homebrew supports Brewfiles, the brew command neither treats it as the source of truth nor writes to it when you brew install <newthing>. With homebrew managed by Nix, any new MacBook I configure does not need me to remember which brews I had on the old one. I moved the source of truth from on-disk to a declarative configuration file.
darwin-rebuild switch installs anything missing and refuses to uninstall anything (cleanup = "none") so I can drift safely until I decide to clean up.
macOS System Settings, normally the least declarative thing after Homebrew, becomes a Nix attribute set:
# modules/darwin/defaults.nix
{
system.defaults.NSGlobalDomain = {
AppleInterfaceStyle = "Dark";
AppleICUForce24HourTime = true;
AppleShowAllExtensions = true;
InitialKeyRepeat = 15;
KeyRepeat = 2;
NSAutomaticQuoteSubstitutionEnabled = false;
NSAutomaticDashSubstitutionEnabled = false;
};
}
The shell prompt, fonts, and signing config all live in similar attribute sets. The starship config is the same kind of value, rendered to TOML at activation time by Home Manager:
# modules/home-manager/shell.nix (excerpt)
programs.starship = {
enable = true;
enableZshIntegration = true;
settings = {
add_newline = true;
format = "$username$hostname$directory$git_branch$git_status$fill$cmd_duration$line_break$character";
right_format = "$status$jobs$direnv$nodejs$bun$golang$rust$python$haskell";
character.success_symbol = "[β―](bold green)";
git_branch.symbol = " ";
fill.symbol = "β";
};
};
The same pattern holds for everything else. Identities and signing keys come from one lib/vars.nix and flow through every module that needs them:
# lib/vars.nix (excerpt)
identities = {
jry = {
name = "Jacob Young";
email = "[email protected]";
sshKey = "${user.home}/.ssh/id_rsa";
autoRules = [ "${user.home}/code/personal/jryio/" ];
};
inf = {
name = "Jacob Young";
email = "[email protected]";
sshKey = "${user.home}/.ssh/infinite-music";
autoRules = [ "${user.home}/code/professional/infinitemusic/" ];
};
};
The git module reads that table and writes both the gitego YAML and the includeIf gitconfig fragments. A new identity is a four-line addition to one file.
Bootstrapping a new machine
The install command is one line:
./install
That script does four things:
- Installs Determinate Nix if
nixis not already present. - Creates
hosts/<LocalHostName>/default.nixif it does not exist, generated fromscutilandhostname. - Creates
flake.lockif it does not exist. - Runs the first
darwin-rebuild switch --flake .#<hostname>.
After that, the machine is the flake. Every later change is a git pull plus a darwin-rebuild switch away. On the AI-agent host I wipe and reinstall on a regular schedule, and the install path is the same one a fresh client MacBook would take. The repository is private; the agent has read access to it and can both consume the setup and propose changes through PRs I review.
Two safety patterns matter more than the code. First, every mutating change goes through a non-primary user called testaccount before it lands on the live one. The flake produces a single darwinConfigurations.<host>, so the test user activates exactly the same configuration the primary user will. If the activation breaks, I roll back system-wide before logging into the primary account. Second, after every switch I run a small smoke harness (scripts/smoke-testaccount.sh) that checks for the presence of Determinate Nix, darwin-rebuild, the Homebrew bridge, the rendered Home Manager zshrc, the 1Password SSH socket, the GPG signing key, the vendored fonts, the gitego config, the system hosts blocklist, and the starship prompt. Eighteen checks, no mutations, exit non-zero on any failure. It is the cheapest insurance I have written.
What declarative does
Terraform exists to move infrastructure into both configuration files + compute code to execute against different providers. You get version control, diffs on changes, and automated CI/CD.
Nix is serving a similar role to IaaC on my MacBook. The win here is that most of my Mac is not going to be βclick-opsβ any longer. I would like to get into LLMs generating AppleScript to allow me to open GUI applications and click through settings/permissions/menus, etc. Maybe thatβs a follow up blog post.
After 15 years on macOS Iβve configured everything exactly how it needs to be.
What I learned
Three things stand out from a few months of running this setup.
First, the hardest part of declarative configuration is not writing the configuration. It is finding the truth on the old machine. Brewfiles drift from brew leaves continuously, launchd plists outlive the apps that wrote them, and macOS rewrites defaults files at runtime so a snapshot is the wrong granularity. LLMs are very good at finding all of these places and I spent many hours in guided chats with Claude to find the niches and nuances of my systemβs settings + de-crufting old configuration.
Second, the validation pattern matters more than any individual module. A switch under testaccount plus a fast smoke harness has caught more regressions than I expected. Thereβs something re-assuring when rerunning an install script wonβt break anything or put your machine into a bad state.
Third, LLM-assisted writing of Nix code is the part of this that genuinely was not possible five years ago. Nix has always been powerful and unfriendly in equal measure. A coding agent that can read four thousand lines of nix-darwin module source, find the right system.defaults key, and write the four lines that set it has changed the calculus of βis it worth declaring this in Nix?β from βprobably notβ to βalmost alwaysβ.
Outcomes
I am able to take a new fresh MacBook to full development parity from my config in roughly 30 minutes of unattended darwin-rebuild switch time, most of which is Homebrew downloading large casks. Future changes are much easier now and marginal in scope. The setup is opinionated to my personal use, but the seams are the same wherever you start.
I would love to hear from you if youβve setup something similar or have different Nix usage requirements/patterns for system configuration.