After losing a MacBook Pro recently, 10 years of hand-written settings I depend on were gone. Backups existed, but all of the little configuration was 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 a backups or a self-hosted MDM profile. 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 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 appreciated the sentiments. It wasn’t until LLMs started getting good did 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.
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 machine 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 Anthalye make 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 were they need to go, and run arbitrary bash.
Dotfiles are not full-system cofiguration, just a few developer tools. It takes more work and effort to configure macOS System Settings, GUI applications, and 1Password. There’s only 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.
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-darwin
launchd
agents defaults write
settings even Homebrew formulas.~
. 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 just zshrc
.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.
manager/
├── flake.nix # entrypoint
├── flake.lock
├── install
├── bootstrap/install.sh
├── lib/vars.nix
├── hosts/ # host-specific condfiguration
│ └── 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.
{
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";
};
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.
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.
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 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.
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;
};
}
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…
[includeIf "gitdir:/Users/CASE/code/professional/keybase/"]
path = /Users/CASE/.gitego/profiles/keybase.gitconfig
This 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 outside of Nix.
#
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
Now, a commit under ~/code/personal/jryio/
is signed by one identity and a commit under ~/code/professional/<client>/
by another.
Finally there re activation scripts 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.
home.activation.vendoredFonts =
lib.hm.dag.entryAfter [ "writeBoundary" ] ''
'';
/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.
The smallest win is Homebrew:
{
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:
{
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:
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:
identities = {
jry = {
name = "Jacob Young";
email = "git@jry.io";
sshKey = "${user.home}/.ssh/id_rsa";
autoRules = [ "${user.home}/code/personal/jryio/" ];
};
inf = {
name = "Jacob Young";
email = "git@sancho.studio";
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.
The install command is one line:
./install
That script does four things:
nix
is not already present.hosts/<LocalHostName>/default.nix
if it does not exist, generated from scutil
and hostname
.flake.lock
if it does not exist.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.
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 need to be.
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”.
I am able to take a new fresh MacBook to full developement parity from my config in roughly 30 minutes of unattended darwin-rebuild switch
time, most of which is Homebrew down 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