My Mac, Declared in Nix
My MacBook runs from a Nix flake using nix-darwin and home-manager. I wanted one command that takes the Mac from whatever state it’s in to the state I wrote down: macOS defaults, CLI tools, dotfiles, Homebrew apps, and secrets. That command is task switch. The first commit went in on 2 June 2026 and the repo is 48 commits in now, most of them small.
Layout
| Path | What it holds |
|---|---|
flake.nix | Wires nix-darwin, home-manager and sops-nix together, plus lint checks |
modules/darwin.nix | System settings, macOS defaults, Homebrew |
modules/home.nix | User packages, git, zsh, Ghostty, AeroSpace, secrets |
secrets/secrets.yaml | Encrypted secrets, committed |
Taskfile.yml | Wrappers for the commands I forget |
Everything is pinned to the 26.05 stable branches: nixpkgs-26.05-darwin, nix-darwin-26.05 and home-manager release-26.05. Each of the other inputs has inputs.nixpkgs.follows = "nixpkgs", so there is one nixpkgs in the closure instead of three. The comment in flake.nix says why the branches match: everything is built and cached together.
Determinate Nix owns Nix
I installed Nix with the Determinate installer. Determinate manages /etc/nix/nix.conf and the daemon itself, and nix-darwin also wants to manage both. If you let them, they fight over the file. So the first real line in darwin.nix turns nix-darwin’s Nix management off:
nix.enable = false;
Flakes are already on in Determinate’s config. Anything extra goes in /etc/nix/nix.custom.conf or the per-user config, which comes up again in the secrets section.
The macOS part
system.defaults maps onto defaults write. My settings are short:
system.defaults = {
dock = {
autohide = true;
show-recents = false;
tilesize = 42;
};
finder = {
AppleShowAllExtensions = true;
FXPreferredViewStyle = "Nlsv"; # list view
ShowPathbar = true;
};
NSGlobalDomain = {
InitialKeyRepeat = 15;
KeyRepeat = 2;
AppleInterfaceStyle = "Dark";
};
};
Homebrew stays, on a leash
I didn’t try to move everything into nixpkgs. GUI apps on macOS are easier through Homebrew casks, and a few CLI tools are either missing from nixpkgs or behave better from brew. nix-darwin can drive Homebrew declaratively, so it does:
homebrew = {
enable = true;
onActivation.cleanup = "none";
onActivation.autoUpdate = true;
onActivation.upgrade = true;
greedyCasks = true;
# taps, casks, brews ...
};
cleanup = "none" is the important choice. nix-darwin installs what’s listed and never removes anything I brew install by hand. The stricter option is "zap", which makes the config the single source of truth and uninstalls anything not listed.
autoUpdate and upgrade make every switch update Homebrew as well, so one command updates both worlds. Switches are slower for it.
The split is roughly: common CLI tools (ripgrep, fd, eza, bat, jq, neovim, tmux, lazygit, go, uv, node, pandoc) live in home.packages; casks (AeroSpace, AltTab, Zed, VS Code, Docker Desktop, Hammerspoon) and a handful of niche formulae live in brew. The system-wide package list is just vim.
Homebrew 6.0 and tap trust
Homebrew 6.0 shipped HOMEBREW_REQUIRE_TAP_TRUST. It refuses to load formulae from third-party taps until you trust them, and the brew bundle step inside darwin-rebuild switch aborted on my non-official taps, which failed the whole switch.
The obvious fix was to write the trust file with home.file. That didn’t work. home.file creates a symlink into the read-only Nix store, brew 6.0 rewrites its trust store in place by following the symlink, hits the store, and stops with “Refusing to write insecure trust store”. So it’s a real file, written by an activation script:
home.activation.homebrewTrust = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
run mkdir -p "$HOME/.homebrew"
run rm -f "$HOME/.homebrew/trust.json"
run printf '%s\n' ${
lib.escapeShellArg (builtins.toJSON {
trustedtaps = [ "go-task/tap" "nikitabobko/tap" ];
})
} > "$HOME/.homebrew/trust.json"
'';
The trusted tap list has to match the taps list in darwin.nix. Right now that’s two lists kept in sync by hand.
Shell
zsh is fully under home-manager now. It replaced a hand-written ~/.zshrc. Oh My Zsh provides the git, github and macos plugins. Autosuggestions and syntax highlighting come from nixpkgs through home-manager options instead of git-cloned OMZ plugins. Powerlevel10k loads as a plugin from pkgs.zsh-powerlevel10k.
p10k’s instant prompt has to run before anything prints, so that block goes in with lib.mkBefore, which puts it at the top of the generated .zshrc. ~/.p10k.zsh itself stays outside Nix, so p10k configure still works.
~/.zprofile runs brew shellenv, which puts /opt/homebrew/bin first. Where brew and Nix both provide a tool, the brew copy won. The fix re-prepends the Nix directories later, in .zshrc:
path=(
/etc/profiles/per-user/$USER/bin
/run/current-system/sw/bin
"$HOME/.nix-profile/bin"
$path
)
There are also aliases: cat to bat, ls to eza, grep to rg, find to fd.
Ghostty and AeroSpace
Ghostty’s config is three lines written by home-manager:
xdg.configFile."ghostty/config".text = ''
command = ${pkgs.tmux}/bin/tmux new-session -A -s main
theme = Catppuccin Mocha
font-size = 15
'';
The ${pkgs.tmux} interpolation is there because moving tmux between brew and Nix broke terminal startup once. Interpolating the store path means the config always points at the tmux Nix installed.
AeroSpace replaced Rectangle. The TOML config lives inline in home.nix and gives each kind of app a lettered workspace: B for browsers, C for editors, T for terminals, A for AI tools, M for messaging, N for notes. Option plus the letter switches, Option+Shift plus the letter moves the focused window there. on-window-detected rules send apps to their workspace by bundle ID when they open, so Zed always lands on C. Option+1 to 9 are scratch workspaces. Option+Control+arrow moves a window to the next monitor.
Secrets in git
I use sops-nix. secrets/secrets.yaml is committed encrypted and decrypted at activation into files outside the Nix store. Two age recipients can decrypt it:
- A key derived from my SSH ed25519 key with
ssh-to-age. sops-nix uses this one automatically at activation, so there’s no separate key file to bootstrap on a new machine. - A standalone age key kept as a recovery key, backed up away from the laptop.
Either one alone is enough, so losing one isn’t a lockout. One catch: if the SSH key ever gets a passphrase, non-interactive decryption at activation breaks.
The most useful secret in there is a GitHub token. Unauthenticated, Nix’s GitHub fetches are limited to 60 requests an hour, and nix flake update hit that limit and failed with HTTP 403. Nix accepts an access-tokens line in nix.conf, but I didn’t want the token in the Nix store, where anything on the machine can read it. sops-nix templates handle that. They render a file outside the store with the secret substituted in:
sops.templates."nix-github-token.conf".content =
"access-tokens = github.com=${config.sops.placeholder.github_token}";
xdg.configFile."nix/nix.conf".text = ''
!include ${config.sops.templates."nix-github-token.conf".path}
'';
The user nix.conf that lands in the store contains only the !include line. !include is the soft form, so a fresh machine where sops hasn’t rendered the file yet doesn’t error.
A lint gate on Linux
The flake exposes three checks: deadnix, statix and nixfmt. They are pure source checks with no macOS dependency, so the flake builds them for aarch64-darwin and x86_64-linux. task check runs them on the Mac. A Forgejo Actions workflow runs the Linux versions on my self-hosted runner for every push and PR.
Passing ${self} to the checks makes Nix warn that the derivation reference is unreliable when the work tree is dirty, because that store path carries no string context. Re-importing the tree with builtins.path gives it context:
src = builtins.path {
path = ./.;
name = "nixos-source";
};
statix also wanted every a.b = 1; a.c = 2; collapsed into a = { b = 1; c = 2; };. I prefer the flat dotted style for home-manager.* and homebrew.onActivation.*, so repeated_keys is disabled in statix.toml.
The runner is Linux, so CI can only prove the Nix is formatted and lints clean. It can’t build the Darwin system. task build on the Mac is still the real gate before a switch.
Taskfile
go-task wraps the commands I use:
| Command | What it runs |
|---|---|
task switch | sudo darwin-rebuild switch --flake .#<host> |
task diff | Build, then nix store diff-closures /run/current-system ./result |
task upgrade | nix flake update, then switch |
task rollback | sudo darwin-rebuild rollback |
task gc | Collect garbage older than 14 days, for root and user |
task diff shows which package versions change before I commit to a switch.
What’s not done
Renovate is half-finished. renovate.json is in the repo and asks for one grouped flake.lock maintenance PR on Monday mornings, with automerge off. The Renovate service itself, a bot account on Forgejo and a dry run all still need doing, so for now I run task update by hand.
The duplicated tap list should become one Nix list referenced from both modules. And I haven’t tested how AeroSpace handles an external display disconnecting and reconnecting.