nix-darwin: Declarative macOS Configuration
nix-darwin: Declarative macOS Configuration
Half the infrastructure desk runs macOS laptops; the other half runs NixOS servers. Without nix-darwin, the macOS machines drift — Homebrew packages installed by hand, defaults write commands nobody remembers, and a setup doc that is always three months out of date. The boring default is: declare macOS system configuration in the same flake that manages Linux, and apply it with darwin-rebuild switch.
Mental model
NixOS uses nixosConfigurations to describe Linux machines. nix-darwin adds darwinConfigurations for macOS machines. Both live in the same flake.nix.
flake.nix
├── nixosConfigurations.desk-server ← nixos-rebuild switch
└── darwinConfigurations.desk-mac ← darwin-rebuild switch
nix-darwin manages:
| Layer | Managed by nix-darwin |
|---|---|
| System packages | environment.systemPackages |
| Homebrew casks | homebrew.casks (via nix-homebrew) |
| launchd daemons & agents | launchd.daemons, launchd.agents |
| macOS defaults | system.defaults.* |
| Environment variables | environment.variables |
| Shell initialisation | programs.zsh, programs.fish |
| Per-user config | Home Manager as a module |
nix-darwin cannot manage SIP-protected system paths, kernel extensions, or anything that macOS’s System Integrity Protection locks away. Attempting to do so produces a clear error at build time — not a silent failure at runtime.
The apply command is the macOS twin of nixos-rebuild switch:
nixos-rebuild switch --flake .#desk-server ← Linux
sudo darwin-rebuild switch --flake .#desk-mac ← macOS
Worked examples
Case 1: Minimal darwinConfiguration
This is the smallest working nix-darwin flake. It sets two system.defaults tweaks that every developer on the team wants: auto-hiding Dock and a fast key-repeat rate.
nix-darwin is not part of nixpkgs. Pin nix-darwin-26.05, not master. A Mac-only flake may use nixpkgs-26.05-darwin (Hydra darwin binaries). A mixed Linux+Mac flake may follows nixos-26.05 — some darwin attrs then build from source.
Save as flake.nix:
# flake.nix
{
description = "desk-mac — minimal nix-darwin configuration";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
nix-darwin = {
url = "github:nix-darwin/nix-darwin/nix-darwin-26.05";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, nix-darwin }:
let
system = "aarch64-darwin"; # M-series Mac; use x86_64-darwin for Intel
pkgs = nixpkgs.legacyPackages.${system};
in
{
darwinConfigurations."desk-mac" = nix-darwin.lib.darwinSystem {
inherit system;
modules = [
({ pkgs, ... }: {
# System packages available to all users
environment.systemPackages = [
pkgs.ripgrep
pkgs.jq
pkgs.git
];
# macOS system defaults
system.defaults = {
dock.autohide = true;
dock.autohide-delay = 0.0;
dock.autohide-time-modifier = 0.2;
NSGlobalDomain.InitialKeyRepeat = 15; # delay before repeat (lower = faster)
NSGlobalDomain.KeyRepeat = 2; # repeat interval (lower = faster)
};
# Integer birth, not a channel string. New 26.05 machines: 6.
# Do not bump to 7 without reading `darwin-rebuild changelog`.
system.stateVersion = 6;
# Activation runs as root. Defaults/homebrew apply to this user.
system.primaryUser = "deskadmin";
# 26.05 channel Nix is 2.34. If the installer already put 2.35+
# on the Mac, omit nix.package so you do not downgrade the daemon.
# nix.package = pkgs.nix;
nixpkgs.config.allowUnfree = true;
})
];
};
};
}Install nix-darwin once on a fresh Mac, then apply as root (activation is root-only):
sudo nix run nix-darwin/nix-darwin-26.05#darwin-rebuild -- switch --flake .#desk-mac
# after the first generation:
sudo sudo darwin-rebuild switch --flake .#desk-macOutput:
building the system configuration...
setting up /etc/zshrc...
setting up /etc/bashrc...
setting up /etc/nix/nix.conf...
system defaults applied: dock.autohide = true, KeyRepeat = 2
reloading Dock...
setting up launchd services...
/run/current-system -> /nix/store/3zqr9m4s...-darwin-system
System generation: desk-mac-1 (2024-11-20)
Every subsequent darwin-rebuild switch is idempotent. Running it twice produces no changes.
Case 2: One flake for Linux and macOS
The desk team maintains one repository. desk-server is a NixOS machine. desk-mac is a developer’s MacBook. Both receive the same core tools from a shared commonPackages list.
Save as flake.nix:
# flake.nix
{
description = "Desk infrastructure — Linux + macOS from one flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
nix-darwin = {
url = "github:nix-darwin/nix-darwin/nix-darwin-26.05";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, nix-darwin }:
let
# Packages every desk engineer needs regardless of OS
commonPackages = pkgs: [
pkgs.ripgrep
pkgs.jq
pkgs.git
pkgs.go
];
in
{
# ── Linux server ────────────────────────────────────────────────────
nixosConfigurations."desk-server" = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
({ pkgs, ... }: {
environment.systemPackages = commonPackages pkgs;
# Server-only: enable the SSH daemon
services.openssh.enable = true;
system.stateVersion = "26.05";
})
];
};
# ── macOS workstation ────────────────────────────────────────────────
darwinConfigurations."desk-mac" = nix-darwin.lib.darwinSystem {
system = "aarch64-darwin";
modules = [
({ pkgs, ... }: {
environment.systemPackages = commonPackages pkgs ++ [
pkgs.mas # Mac App Store CLI — macOS only
];
system.defaults.dock.autohide = true;
system.stateVersion = 6;
system.primaryUser = "deskadmin";
nixpkgs.config.allowUnfree = true;
})
];
};
};
}Apply to the server (run on the Linux machine):
nixos-rebuild switch --flake .#desk-serverOutput:
building the system configuration...
activating the configuration...
setting up /etc/ssh/sshd_config...
starting sshd.service
Apply to the Mac (run on the macOS machine):
sudo darwin-rebuild switch --flake .#desk-macOutput:
building the system configuration...
system defaults applied
setting up /etc/zshrc...
/run/current-system -> /nix/store/7f2kp1rs...-darwin-system
ripgrep, jq, git, and go are now in /run/current-system/sw/bin on both machines. Both were updated from a single source of truth.
Case 3: Declarative Homebrew casks via nix-homebrew
nix-darwin can drive Homebrew declaratively through the nix-homebrew module. GUI macOS apps — which are not in nixpkgs — are listed in code and installed automatically during darwin-rebuild switch. No manual brew install --cask commands.
nix-homebrew is a separate flake input that bridges nix-darwin and Homebrew. It installs Homebrew itself under /opt/homebrew if absent and keeps the tap and cask list in sync.
Save as flake.nix:
# flake.nix
{
description = "desk-mac with declarative Homebrew casks";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
nix-darwin = {
url = "github:nix-darwin/nix-darwin/nix-darwin-26.05";
inputs.nixpkgs.follows = "nixpkgs";
};
nix-homebrew = {
url = "github:zhaofengli/nix-homebrew";
inputs.nixpkgs.follows = "nixpkgs";
};
homebrew-core = {
url = "github:homebrew/homebrew-core";
flake = false;
};
homebrew-cask = {
url = "github:homebrew/homebrew-cask";
flake = false;
};
};
outputs = { self, nixpkgs, nix-darwin, nix-homebrew,
homebrew-core, homebrew-cask }:
{
darwinConfigurations."desk-mac" = nix-darwin.lib.darwinSystem {
system = "aarch64-darwin";
modules = [
# Wire in nix-homebrew
nix-homebrew.darwinModules.nix-homebrew
{
nix-homebrew = {
enable = true;
enableRosetta = true; # required on Apple Silicon for x86_64 casks
user = "alice"; # the user that owns the Homebrew prefix
taps = {
"homebrew/homebrew-core" = homebrew-core;
"homebrew/homebrew-cask" = homebrew-cask;
};
# Prevent Homebrew from auto-updating outside nix-darwin
autoMigrate = true;
};
}
({ pkgs, ... }: {
environment.systemPackages = [
pkgs.ripgrep
pkgs.jq
pkgs.git
];
# Casks are GUI apps not available in nixpkgs
homebrew = {
enable = true;
casks = [
"ghostty" # GPU-accelerated terminal
"1password" # password manager
"arc" # browser
];
# Remove casks that are no longer in the list on next switch
onActivation.cleanup = "zap";
};
system.stateVersion = 6;
system.primaryUser = "deskadmin";
nixpkgs.config.allowUnfree = true;
})
];
};
};
}Apply the configuration:
sudo darwin-rebuild switch --flake .#desk-macOutput:
building the system configuration...
==> Homebrew taps are up to date.
==> Installing cask ghostty
==> Installing cask 1password
==> Installing cask arc
system defaults applied
/run/current-system -> /nix/store/9xbp3v7c...-darwin-system
Adding a cask is a one-line diff in homebrew.casks. Removing it from the list and running darwin-rebuild switch uninstalls it automatically because onActivation.cleanup = "zap".
Case 4: system.defaults macOS tweaks
system.defaults is nix-darwin’s typed interface to defaults write. Every key maps to a known macOS preference domain. Values are type-checked at build time — a typo is a build error, not a silent no-op.
This module captures the settings the desk team applies to every macOS workstation. Save it as a standalone module imported by the main flake.nix.
Save as macos-defaults.nix:
# macos-defaults.nix
{ pkgs, lib, ... }:
{
system.defaults = {
# ── Finder ────────────────────────────────────────────────────────────
finder = {
AppleShowAllExtensions = true; # always show .go, .nix, .yaml suffixes
AppleShowAllFiles = true; # show hidden dot-files
ShowPathbar = true; # breadcrumb at bottom of every window
ShowStatusBar = true; # file count and disk space in status bar
FXPreferredViewStyle = "Nlsv"; # default to list view
FXDefaultSearchScope = "SCcf"; # search current folder by default
_FXSortFoldersFirst = true; # directories appear before files
};
# ── Keyboard ─────────────────────────────────────────────────────────
NSGlobalDomain = {
ApplePressAndHoldEnabled = false; # disable character picker; enables key repeat
InitialKeyRepeat = 15; # 225 ms before repeat starts (default: 25)
KeyRepeat = 2; # 30 ms between repeats (default: 6)
};
# ── Dock ─────────────────────────────────────────────────────────────
dock = {
autohide = true;
autohide-delay = 0.0;
autohide-time-modifier = 0.2;
show-recents = false; # no "recent apps" section
tilesize = 48;
minimize-to-application = true; # minimise into app icon, not Dock shelf
mru-spaces = false; # keep Space order fixed
};
# ── Screenshots ───────────────────────────────────────────────────────
screencapture = {
location = "/Users/alice/Screenshots";
type = "png";
disable-shadow = true;
};
# ── Trackpad ─────────────────────────────────────────────────────────
trackpad = {
Clicking = true; # tap-to-click
TrackpadThreeFingerDrag = true;
};
};
# Suppress .DS_Store on network and USB volumes via activation script
system.activationScripts.extraActivation.text = ''
defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool true
defaults write com.apple.desktopservices DSDontWriteUSBStores -bool true
'';
}Import it from your flake:
# flake.nix (modules list excerpt)
modules = [
./macos-defaults.nix
({ pkgs, ... }: {
environment.systemPackages = [ pkgs.ripgrep pkgs.jq pkgs.git ];
system.stateVersion = 6;
system.primaryUser = "deskadmin";
})
];Apply:
sudo darwin-rebuild switch --flake .#desk-macOutput:
building the system configuration...
system defaults applied:
finder.AppleShowAllExtensions = true
finder.ShowPathbar = true
dock.autohide = true
NSGlobalDomain.KeyRepeat = 2
NSGlobalDomain.InitialKeyRepeat = 15
reloading Dock...
reloading Finder...
/run/current-system -> /nix/store/2njw8x9q...-darwin-system
Preferences take effect immediately. InitialKeyRepeat and KeyRepeat take effect after the next login.
The trap
nix-darwin does not replace the macOS kernel. It manages user space. Developers who have used NixOS sometimes reach for boot.kernelModules, boot.kernelParams, or hardware.* options in a darwinConfiguration. These options do not exist on Darwin.
Attempting it:
# flake.nix ← WRONG: kernel options are Linux-only
darwinConfigurations."desk-mac" = nix-darwin.lib.darwinSystem {
system = "aarch64-darwin";
modules = [
({ ... }: {
boot.kernelModules = [ "wireguard" ]; # does not exist on Darwin
})
];
};Run:
sudo darwin-rebuild switch --flake .#desk-macOutput:
error: The option `boot.kernelModules' does not exist. Definition values:
- In anonymous module at `/nix/store/.../flake.nix':
[ "wireguard" ]
(use '--show-trace' to show detailed location information)
The error fires at build time, before any change touches the live system.
The fix: On macOS, kernel extensions must be installed through Apple-signed packages or system recovery. WireGuard on macOS runs as a userspace process (wireguard-go), not a kernel module. Declare the wireguard-go binary in environment.systemPackages instead.
Similarly, SIP (System Integrity Protection) prevents nix-darwin from writing to /System, /usr (except /usr/local), or /sbin. Do not attempt to place files in those paths. Everything belongs in /nix/store and /run/current-system, which SIP does not protect.
The boring rule
- One
flake.nixholds bothnixosConfigurationsanddarwinConfigurations. - Share tooling via a
commonPackagesfunction; append OS-specific packages in each configuration. - Use
system.defaultsfor every preference that would otherwise be adefaults writecommand. - Run Home Manager as a
nix-darwinmodule (home-manager.darwinModules.home-manager) for per-user dot-file management. - Use
nix-homebrewto manage Homebrew casks declaratively; setonActivation.cleanup = "zap"so removed casks are uninstalled automatically. - Run
darwin-rebuild switch --flake .#<hostname>after every change. No change is complete until the switch succeeds. - Pin
nix-darwin/nix-darwin-26.05, notLnL7/…/master. Setsystem.primaryUser.stateVersionis an integer (birth). - Do not fight SIP. If an operation requires disabling SIP, it belongs in a one-time provisioning script during machine enrollment — not in
nix-darwin. sudo darwin-rebuild. Do not setnix.package = pkgs.nixif that would downgrade a 2.35+ daemon to channel 2.34.
Try this
Add a cask. Add
"rectangle"(a window manager) tohomebrew.casksin Case 3’s flake and rundarwin-rebuild switch. ConfirmRectangle.appappears in/Applications. Then remove it from the list, switch again, and verify it is gone.Shared package drift. Fork Case 2’s flake. Add
pkgs.kubectltocommonPackages. Apply to bothdesk-server(vianixos-rebuild switch) anddesk-mac(viadarwin-rebuild switch). Confirmkubectl version --clientreturns the same version string on both machines.Defaults archaeology. On a macOS machine, run
defaults read com.apple.dock | grep autohideand note the current value. Addsystem.defaults.dock.autohide = trueto Case 1’s flake, switch, and run the samedefaults readcommand again to confirm the value changed.Trigger the trap deliberately. In a
darwinConfiguration, addboot.kernelModules = [ "wireguard" ]and rundarwin-rebuild switch. Read the error message. Then replace it withenvironment.systemPackages = [ pkgs.wireguard-go ], switch again, and confirmwg-quickis now on$PATH.