Notes on building systems that work and other things... maybe?

A year of Nix on macOS

· 7 min

I remember the exact moment I decided to try Nix. It was a Thursday night, somewhere around 11pm, and I was staring at a broken terminal after a macOS update had silently reset my dock preferences, broken three Homebrew symlinks, and somehow changed my default Python. Again. I'd been maintaining my dotfiles since January 2021 (vim plugins, zsh themes, git configs, the whole ritual) and every few months something would just... stop working. No clear reason. Just vibes.

So on January 17, 2025, I added a flake.nix to my dotfiles repo. I had no idea what I was doing.

The before times

Let me paint the picture of what I had. A .zshrc that had grown organically over four years. A Brewfile that was mostly accurate. An install.sh that I wrote once, ran once, and never touched again because I was afraid of what it would do. Config files scattered across ~/.config/ with no clear record of which ones I'd manually edited and which ones came from the repo.

The setup worked. I could write code, push commits, deploy services. But every time I needed to set up a new machine or debug a broken path, I'd spend half a day reading my own shell history to figure out what I'd done six months ago.

The worst part was the terminal emulator situation. I'd gone from Alacritty to Kitty to Ghostty, and each migration left behind config files, PATH entries, and font caches that I'd forget about until they caused some weird conflict.

Finding Nix

Nix is one of those tools that everyone describes differently. "It's a package manager." "It's a build system." "It's a functional programming language for system configuration." All of these are technically true and none of them helped me understand why I should care.

What finally clicked was this: Nix lets you describe what your system should look like, and then it makes it look like that. Change the description, it computes the diff. Break something, roll back. Nothing else to it.

On macOS, nix-darwin extends this to system-level stuff (Homebrew packages, macOS defaults, keyboard mappings, firewall rules) and Home Manager handles the user-level things (dotfiles, shell aliases, environment variables).

My system is now two files and a Makefile. flake.nix for the system. home.nix for my user environment. make rebuild to apply everything. No install script. No manual steps. No "did I remember to run that command."

How it actually happened

It wasn't instant. Looking back through my git history, I can trace exactly how it unfolded.

The first flake.nix in January 2025 was cautious. I just moved a few packages in: neovim, ripgrep, jq, rustup. I also added nil (the Nix language server) and nixfmt because editing Nix files without tooling is like writing Rust without rust-analyzer. Technically possible, practically miserable.

Two months later I added Home Manager, and that was the moment everything changed. Before, I was symlinking config files with a shell script and crossing my fingers. Now I could just declare them:

home.file.".config/ghostty/config".source = ./config/ghostty/config;
home.file.".config/git/config".source = ./config/git/config;
home.file.".config/zed/settings.json".source = ./config/zed/settings.json;

Change the source file, run make rebuild, the symlink updates. No ln -sf. No checking if the target exists. No cleanup of stale links from three terminal emulators ago.

Then in May I made a mistake. I tried to manage Homebrew entirely through Nix using a thing called nix-homebrew. The idea was beautiful: one system to rule them all. The reality was fragile. Homebrew really wants to own its own state. Taps, cask tokens, update cycles. It has opinions about these things, and fighting those opinions was causing more problems than it solved.

So I ripped it out. Instead, I let nix-darwin manage Homebrew declaratively without trying to replace it:

homebrew = {
  enable = true;
  onActivation.cleanup = "zap";
  onActivation.autoUpdate = true;
  onActivation.upgrade = true;
  brews = [ "go" "gh" "lazygit" "grpcurl" ... ];
  casks = [ "ghostty" "zed" "docker-desktop" ... ];
};

That onActivation.cleanup = "zap" line is the important one. It means: if a package is not in this list, remove it. The flake becomes the source of truth. If you brew install something manually, it gets removed on the next rebuild. This forces you to declare everything. At first it felt annoying. Then it felt liberating.

The PATH weekend

This one deserves its own section because it took longer than I'd like to admit.

Nix and Homebrew both want to control what's on your PATH. The Nix daemon needs to be sourced before anything else, but Homebrew's shellenv also modifies PATH. Get the ordering wrong and you end up running the system git instead of Homebrew's, or the Homebrew python instead of pyenv's. I spent a lot of time staring at echo $PATH output before I landed on this:

programs.zsh.shellInit = ''
  if [ -e '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' ]; then
    . '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh'
  fi
  export PATH="/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:$PATH"
'';

Nix paths first, then Homebrew via shellenv, then user paths. Simple in retrospect. Not simple at 2am.

Where it got fun

Once the foundation was solid, I started pulling in everything I used to configure manually. Keyboard remapping in early 2026 (Caps Lock to Escape, because life is too short for Caps Lock). File associations with duti shortly after (Zed as default editor for 38 file types). And the one that made me irrationally happy: macOS defaults.

system.defaults = {
  dock = {
    autohide = true;
    autohide-delay = 0.0;
    autohide-time-modifier = 0.15;
    show-recents = false;
    tilesize = 60;
  };
  NSGlobalDomain = {
    AppleInterfaceStyle = "Dark";
    InitialKeyRepeat = 14;
    KeyRepeat = 1;
    ApplePressAndHoldEnabled = false;
    NSAutomaticCapitalizationEnabled = false;
    NSAutomaticSpellingCorrectionEnabled = false;
  };
};

My dock animation speed used to get reset every macOS update. I'd forget, notice it three days later, Google the defaults write command, run it, forget it again. Now make rebuild handles it. Forever.

Security went in too. Firewall with stealth mode, Touch ID for sudo, GPG signing on all commits. All in the flake, all reproducible.

networking.applicationFirewall = {
  enable = true;
  enableStealthMode = true;
};
security.pam.services.sudo_local.touchIdAuth = true;

The daemon problem

I need to be honest about this because no one else seems to talk about it: the Nix daemon on macOS is flaky. After system updates, sleep/wake cycles, or sometimes for no apparent reason, it just stops responding. nix-store --version hangs. Everything breaks.

I got tired of debugging this manually, so I wrote a script that handles both Determinate Systems and standard Nix installations:

if [ -f "/Library/LaunchDaemons/systems.determinate.nix-store.plist" ]; then
    sudo launchctl bootout system/systems.determinate.nix-store
    sudo launchctl bootstrap system /Library/LaunchDaemons/systems.determinate.nix-store.plist
    sudo launchctl bootstrap system /Library/LaunchDaemons/systems.determinate.nix-daemon.plist
fi

The Makefile runs this before every rebuild. It adds about a second. Worth it to never think about the daemon again.

The Homebrew boundary

After a year of trial and error, I learned something I wish someone had told me upfront: don't try to replace Homebrew. Just control it.

Nix is great for pure binaries that work identically on any platform. neovim, ripgrep, jq, tree-sitter. But the moment you need something with macOS-specific compilation (like postgresql@18) or a GUI app (like Ghostty or Zed), Homebrew is just better at it. Fighting that boundary wastes time. The onActivation.cleanup = "zap" pattern is the right answer. Let Homebrew be Homebrew. Just make sure the flake decides what it installs.

I wasted weeks learning this the hard way. You don't have to.

One year later

My dotfiles repo started in January 2021 with vim plugins and a zshrc. Five years later it's a complete, declarative system specification. I've used make rebuild to set up a fresh Mac. Took under 20 minutes, most of that just waiting for downloads.

But honestly, the fresh install scenario almost never happens. The real win is smaller and more frequent. A macOS update breaks something? Rebuild. New project needs a tool? One line, rebuild. Regret it? Rollback. Every change is a commit. Every state is recoverable.

That's what Nix gives you. Not magic. Just a system that remembers what it's supposed to look like, even when you don't.