{"slug": "notfiles-or-how-i-learned-to-stop-worrying-and-love-nix", "title": "Notfiles: or  how I learned to stop worrying and love Nix", "summary": "A developer lost a decade of macOS configuration after a MacBook Pro failure, motivating them to rebuild their entire system setup using Nix-based declarative configuration files. The project aims to instantly recreate a fully configured MacBook—including 173 packages, 196 system fonts, GUI apps, licenses, and system settings—without relying on backups or MDM profiles, enabling reproducible machine setups in under 30 minutes.", "body_md": "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.\n\nThis 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.\n\nI 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.\n\nAside 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.\n\n[Nix](https://nixos.org) 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.\n\nTo 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.\n\nMy first dotfile commit landed on August 3rd, 2015. It was a `~/.zshrc`\n\nsnippet that ran `ls`\n\nafter every `cd`\n\nbecause 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.\n\nThen Anish Anthalye make a Python tool called [ dotbot](https://github.com/anishathalye/dotbot) which read a YAML manifest and symlinked tracked files into\n\n`$HOME`\n\n. 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`\n\nand 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.\n\nDotfiles 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.\n\nI’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.\n\nNix 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`\n\n, which mutates `/usr/local`\n\nand hopes for the best, Nix produces a content-addressed **artifact** under `/nix/store/<hash>-<name>`\n\nand links to it from your `PATH`\n\n.\n\nTwo community projects do the work I actually wanted.\n\n`nix-darwin`\n\n`launchd`\n\nagents `defaults write`\n\nsettings even Homebrew formulas.`~`\n\n. 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`\n\n.For the actual Nix install on macOS I use [Determinate Nix](https://determinate.systems). The vanilla Nix installer works, but Determinate ships a friendlier daemon with some sensible default configurations and an official `nix-darwin`\n\n.\n\nThe full toolchain is Determinate Nix at the bottom, `nix-darwin`\n\nfor system-level state, Home Manager for user-level state, and a [Nix flake](https://nix.dev/concepts/flakes.html) that pins all three to the same release.\n\n```\nmanager/\n├── flake.nix                                    # entrypoint\n├── flake.lock\n├── install\n├── bootstrap/install.sh\n├── lib/vars.nix\n├── hosts/                                 # host-specific condfiguration\n│   └── AVA/default.nix\n├── modules/\n│   ├── darwin/                            # nix-darwin to set macOS settings + packages\n│   │   ├── base.nix\n│   │   ├── packages.nix\n│   │   ├── shell.nix\n│   │   ├── homebrew.nix\n│   │   ├── defaults.nix\n│   │   ├── launchd.nix\n│   │   └── hosts.nix\n│   └── home-manager/                      # dotfile, ssh, TTY, script, shell management\n│       ├── base.nix\n│       ├── packages.nix\n│       ├── shell.nix\n│       ├── shell/{init,env,profile,completion-styles,zsh-options,git-aliases}.zsh\n│       ├── editors.nix\n│       ├── ghostty.nix\n│       ├── git.nix\n│       ├── gpg.nix\n│       ├── ssh.nix\n│       ├── tmux.nix\n│       └── ...\n└── scripts/smoke-testaccount.sh\n```\n\n`flake.nix`\n\nis the entry point. It pins inputs, discovers hosts from the `hosts/`\n\ndirectory, and assembles a `darwinConfiguration`\n\nper host.\n\n```\n# flake.nix\n{\n  description = \"macOS bootstrap with Determinate Nix, nix-darwin, and Home Manager\";\n\n  inputs = {\n    nixpkgs.url      = \"github:NixOS/nixpkgs/nixpkgs-25.11-darwin\";\n    nix-darwin.url   = \"github:nix-darwin/nix-darwin/nix-darwin-25.11\";\n    home-manager.url = \"github:nix-community/home-manager/release-25.11\";\n    determinate.url  = \"https://flakehub.com/f/DeterminateSystems/determinate/3\";\n\n    nix-darwin.inputs.nixpkgs.follows   = \"nixpkgs\";\n    home-manager.inputs.nixpkgs.follows = \"nixpkgs\";\n  };\n\n  # build everything for this host\n  outputs = inputs@{ self, nixpkgs, nix-darwin, home-manager, ... }: {\n    darwinConfigurations = lib.genAttrs hostNames mkDarwinConfiguration;\n  };\n}\n```\n\nEach host directory holds the per-machine facts: `hostname`\n\n, `username`\n\n, home directory, and system architecture.\n\nAnything reusable between hosts like constants (full name, signing key, per-identity table) live in a single `lib/vars.nix`\n\nso I never re-type them.\n\nThe Homebrew and macOS defaults examples are pretty straightforward, but there are few modules that ended up mattering more.\n\nHome Manager manages the config file the tools already read.\n\nFor SSH `programs.ssh`\n\nwrites a normal `~/.ssh/config`\n\n, and `services.gpg-agent`\n\nwrites a normal `gpg-agent.conf`\n\n. This places files in the appropriate place without SSH or GPG knowing that nix is involved.\n\n```\n# modules/home-manager/gpg.nix (excerpt)\nservices.gpg-agent = {\n  enable           = true;\n  enableSshSupport = false;        # 1Password is the only SSH agent\n  defaultCacheTtl  = 600;\n  maxCacheTtl      = 7200;\n  pinentry.package = null;         # pinentry-mac comes from the brew bridge\n  extraConfig      = \"pinentry-program /opt/homebrew/bin/pinentry-mac\";\n};\n```\n\nWhen a tool does not have a matching entry in `modules/*`\n\n, I can place individual files using `home.file.\"<path>\".text`\n\nor `.source`\n\nplaces an arbitrary file into the home directory `~`\n\nand tracks it in the same generation.\n\nI figured out how to keep secrets entirely out of Nix configuration. All files placed under `/nix/store`\n\nare world-readable, so a private key written into a derivation is a key exposed to every process on the machine.\n\nI 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.\n\n``` js\n# modules/home-manager/ssh.nix (excerpt)\nlet\n  onePasswordSocket =\n    \"${config.home.homeDirectory}/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock\";\n  identityAgent = ''\"${onePasswordSocket}\"'';\nin {\n  programs.ssh = {\n    enable              = true;\n    enableDefaultConfig = false;\n    matchBlocks.\"*\".extraOptions.IdentityAgent = identityAgent;\n  };\n}\n```\n\nI have a lot of Git identities, some for specific clients, some for personal work, and others for professional work at Sancho Studio.\n\nIf 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`\n\nfile and add a path-based matching rule under `includeIf`\n\nlike so…\n\n```\n# gitego auto-switch rule\n[includeIf \"gitdir:/Users/CASE/code/professional/keybase/\"]\n    path = /Users/CASE/.gitego/profiles/keybase.gitconfig\n```\n\nThis issue with this is that you also have to do something equivalent over in `~/ssh/config`\n\nand 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`\n\ncan save you.\n\nI found a solution in the excellent tool called [git-ego](https://github.com/bgreenwell/git-ego) which manages and switches Git committing identities at runtime based on the CWD. It’s configured with `config.yaml`\n\n, a per-identity gitconfig fragment, and the `includeIf`\n\nrules that select an identity by directory. It works flawlessly ~ you should be using outside of Nix.\n\n```\n# modules/home-manager/git.nix (excerpt)\n#\n# use Nix to deifne all of my git indentities in lib/vars.nix, then map over them\n# to generate matching profiles for git-ego to read under\n# ~/.gitego/profiles/<identity>.gitconfig\nincludeIfBlocks = lib.flatten (lib.mapAttrsToList\n  (id: i: map\n    (rule: {\n      condition = \"gitdir:${rule}\";\n      path      = \"${config.home.homeDirectory}/.gitego/profiles/${id}.gitconfig\";\n    })\n    i.autoRules)\n  identities);\n\nprograms.git.includes = includeIfBlocks;\n```\n\nNow I have two tools working in my favor\n\nNow, a commit under `~/code/personal/jryio/`\n\nis signed by one identity and a commit under `~/code/professional/<client>/`\n\nby another.\n\nFinally 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`\n\nwill not register fonts that are symlinks into the Nix store, so the obvious `home.file`\n\nsymlink 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`\n\n, records a manifest, and restarts `fontd`\n\nonly when the manifest changed.\n\n```\n# modules/home-manager/fonts.nix (shape)\nhome.activation.vendoredFonts =\n  lib.hm.dag.entryAfter [ \"writeBoundary\" ] ''\n    # install -m 0644 each vendored font into ~/Library/Fonts\n    # track ownership in ~/.local/state/manager/fonts.manifest\n    # killall -u \"$USER\" fontd only when the manifest changed\n  '';\n```\n\n`/etc/hosts`\n\nis the same situation: `nix-darwin`\n\n25.11 exposes no `networking.hosts`\n\noption on macOS, so a `system.activationScripts.postActivation`\n\nhook 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.\n\nThe smallest win is Homebrew:\n\n```\n# modules/darwin/homebrew.nix\n{\n  homebrew = {\n    enable = true;\n    onActivation = {\n      autoUpdate = false;\n      upgrade    = false;\n      cleanup    = \"none\";\n    };\n\n    taps = [ \"charmbracelet/tap\" \"oven-sh/bun\" \"stripe/stripe-cli\" ];\n\n    brews = [ \"agent-browser\" \"bat\" \"caddy\" \"ccusage\" \"fzf\" \"ripgrep\" ];\n\n    casks = [\n      \"1password-cli\"\n      \"ghostty\"\n      \"gpg-suite\"\n      \"timemachineeditor\"\n      \"wireshark-app\"\n    ];\n\n    masApps = {\n      \"1Password for Safari\" = 1569813296;\n      \"Things\"               = 904280696;\n      \"Xcode\"                = 497799835;\n    };\n  };\n}\n```\n\nWhile Homebrew supports Brewfiles, the `brew`\n\ncommand neither treats it as the source of truth nor writes to it when you `brew install <newthing>`\n\n. 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.\n\n`darwin-rebuild switch`\n\ninstalls anything missing and refuses to uninstall anything (`cleanup = \"none\"`\n\n) so I can drift safely until I decide to clean up.\n\nmacOS System Settings, normally the least declarative thing after Homebrew, becomes a Nix attribute set:\n\n```\n# modules/darwin/defaults.nix\n{\n  system.defaults.NSGlobalDomain = {\n    AppleInterfaceStyle                 = \"Dark\";\n    AppleICUForce24HourTime             = true;\n    AppleShowAllExtensions              = true;\n    InitialKeyRepeat                    = 15;\n    KeyRepeat                           = 2;\n    NSAutomaticQuoteSubstitutionEnabled = false;\n    NSAutomaticDashSubstitutionEnabled  = false;\n  };\n}\n```\n\nThe shell prompt, fonts, and signing config all live in similar attribute sets. The `starship`\n\nconfig is the same kind of value, rendered to TOML at activation time by Home Manager:\n\n```\n# modules/home-manager/shell.nix (excerpt)\nprograms.starship = {\n  enable               = true;\n  enableZshIntegration = true;\n  settings = {\n    add_newline  = true;\n    format       = \"$username$hostname$directory$git_branch$git_status$fill$cmd_duration$line_break$character\";\n    right_format = \"$status$jobs$direnv$nodejs$bun$golang$rust$python$haskell\";\n\n    character.success_symbol = \"[❯](bold green)\";\n    git_branch.symbol        = \" \";\n    fill.symbol              = \"─\";\n  };\n};\n```\n\nThe same pattern holds for everything else. Identities and signing keys come from one `lib/vars.nix`\n\nand flow through every module that needs them:\n\n```\n# lib/vars.nix (excerpt)\nidentities = {\n  jry = {\n    name      = \"Jacob Young\";\n    email     = \"git@jry.io\";\n    sshKey    = \"${user.home}/.ssh/id_rsa\";\n    autoRules = [ \"${user.home}/code/personal/jryio/\" ];\n  };\n  inf = {\n    name      = \"Jacob Young\";\n    email     = \"git@sancho.studio\";\n    sshKey    = \"${user.home}/.ssh/infinite-music\";\n    autoRules = [ \"${user.home}/code/professional/infinitemusic/\" ];\n  };\n};\n```\n\nThe git module reads that table and writes both the gitego YAML and the `includeIf`\n\ngitconfig fragments. A new identity is a four-line addition to one file.\n\nThe install command is one line:\n\n```\n./install\n```\n\nThat script does four things:\n\n`nix`\n\nis not already present.`hosts/<LocalHostName>/default.nix`\n\nif it does not exist, generated from `scutil`\n\nand `hostname`\n\n.`flake.lock`\n\nif it does not exist.`darwin-rebuild switch --flake .#<hostname>`\n\n.After that, the machine is the flake. Every later change is a `git pull`\n\nplus a `darwin-rebuild switch`\n\naway. 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.\n\nTwo safety patterns matter more than the code. First, every mutating change goes through a non-primary user called `testaccount`\n\nbefore it lands on the live one. The flake produces a single `darwinConfigurations.<host>`\n\n, 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`\n\n) that checks for the presence of Determinate Nix, `darwin-rebuild`\n\n, 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.\n\nTerraform 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.\n\nNix 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.\n\nAfter 15 years on macOS I’ve configured everything exactly how it need to be.\n\nThree things stand out from a few months of running this setup.\n\nFirst, 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`\n\ncontinuously, `launchd`\n\nplists 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.\n\nSecond, the validation pattern matters more than any individual module. A switch under `testaccount`\n\nplus 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.\n\nThird, 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`\n\nmodule source, find the right `system.defaults`\n\nkey, 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”.\n\nI am able to take a new fresh MacBook to full developement parity from my config in roughly 30 minutes of unattended `darwin-rebuild switch`\n\ntime, 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.\n\nI would love to hear from you if you’ve setup something similar or have different Nix usage requirements/patterns for system configuration", "url": "https://wpnews.pro/news/notfiles-or-how-i-learned-to-stop-worrying-and-love-nix", "canonical_source": "https://jry.io/writing/stop-worrying-love-nix/", "published_at": "2026-05-27 00:00:00+00:00", "updated_at": "2026-05-31 13:56:40.679638+00:00", "lang": "en", "topics": ["ai-tools"], "entities": ["Nix", "Homebrew", "App Store", "1Password", "GPG", "MacBook Pro", "Zed", "Helix"], "alternates": {"html": "https://wpnews.pro/news/notfiles-or-how-i-learned-to-stop-worrying-and-love-nix", "markdown": "https://wpnews.pro/news/notfiles-or-how-i-learned-to-stop-worrying-and-love-nix.md", "text": "https://wpnews.pro/news/notfiles-or-how-i-learned-to-stop-worrying-and-love-nix.txt", "jsonld": "https://wpnews.pro/news/notfiles-or-how-i-learned-to-stop-worrying-and-love-nix.jsonld"}}