cd /news/ai-tools/notfiles-or-how-i-learned-to-stop-wo… · home topics ai-tools article
[ARTICLE · art-19198] src=jry.io pub= topic=ai-tools verified=true sentiment=· neutral

Notfiles: or how I learned to stop worrying and love Nix

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.

read13 min publishedMay 27, 2026

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

── more in #ai-tools 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/notfiles-or-how-i-le…] indexed:0 read:13min 2026-05-27 ·