nix-darwin: Declarative macOS Configuration

Updated

September 12, 2026

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-mac

Output:

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-server

Output:

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-mac

Output:

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-mac

Output:

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-mac

Output:

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-mac

Output:

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.nix holds both nixosConfigurations and darwinConfigurations.
  • Share tooling via a commonPackages function; append OS-specific packages in each configuration.
  • Use system.defaults for every preference that would otherwise be a defaults write command.
  • Run Home Manager as a nix-darwin module (home-manager.darwinModules.home-manager) for per-user dot-file management.
  • Use nix-homebrew to manage Homebrew casks declaratively; set onActivation.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, not LnL7/…/master. Set system.primaryUser. stateVersion is 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 set nix.package = pkgs.nix if that would downgrade a 2.35+ daemon to channel 2.34.

Try this

  1. Add a cask. Add "rectangle" (a window manager) to homebrew.casks in Case 3’s flake and run darwin-rebuild switch. Confirm Rectangle.app appears in /Applications. Then remove it from the list, switch again, and verify it is gone.

  2. Shared package drift. Fork Case 2’s flake. Add pkgs.kubectl to commonPackages. Apply to both desk-server (via nixos-rebuild switch) and desk-mac (via darwin-rebuild switch). Confirm kubectl version --client returns the same version string on both machines.

  3. Defaults archaeology. On a macOS machine, run defaults read com.apple.dock | grep autohide and note the current value. Add system.defaults.dock.autohide = true to Case 1’s flake, switch, and run the same defaults read command again to confirm the value changed.

  4. Trigger the trap deliberately. In a darwinConfiguration, add boot.kernelModules = [ "wireguard" ] and run darwin-rebuild switch. Read the error message. Then replace it with environment.systemPackages = [ pkgs.wireguard-go ], switch again, and confirm wg-quick is now on $PATH.