Declarative macOS with Nix: A Practical Guide

  • Julien
    Julien
    Full-Stack Developer

If you've been using a Mac for development, you're probably familiar with the ritual: install Homebrew, brew install your tools, scatter dotfiles across your home directory, and hope you remember what you did when it's time to set up a new machine.

I recently migrated my entire macOS setup to Nix, and while the learning curve was real, the payoff is worth it. This guide walks through why you'd want to do this and how to actually pull it off.

Why Bother?

The pitch for Nix is simple: declarative, reproducible system configuration. Instead of running commands and hoping you remember them later, you define your entire setup in version-controlled config files. New machine? Clone your repo, run one command, done.

Here's what sold me:

  • Single source of truth: One directory contains your entire system config
  • Reproducible: The same config produces the same system, every time
  • Atomic updates: Changes are transactional—if something breaks, roll back instantly
  • Version controlled: Git tracks every change to your setup
  • Scalable for Agencies: For teams, this solves onboarding. We can spin up new machines for coworkers with identical, secure environments instantly. It eliminates the "it works on my machine" friction because everyone is essentially running the exact same machine.

The AI Advantage

There is a modern bonus to this approach: Nix is incredibly AI-friendly. Because your system state is defined explicitly in code, AI tools (like Claude Code or Cursor) can easily read, understand, and maintain your environment. Instead of an agent having to guess if a tool is hidden in global npm, a Homebrew cellar, a DMG manual install, or the Mac App Store, it simply looks at your config file. It knows exactly where everything is, making automated updates and dependency management reliable rather than a guessing game.

The tradeoff is complexity. Nix has its own language, its own package manager, and concepts that take time to internalize. But if you've ever spent an afternoon trying to recreate your setup on a new laptop, that complexity starts looking like a fair trade.

The Stack

We'll use three tools:

  • nix-darwin: System-level macOS configuration (packages, system preferences, services)
  • home-manager: User-level config (dotfiles, shell, programs)
  • Flakes: The modern way to pin dependencies and make builds reproducible

The key insight: we're not going all-or-nothing. Homebrew stays for GUI apps (managing casks declaratively through nix-darwin), while CLI tools move to Nix.

Getting Started

First, install Nix. The Determinate installer is the smoothest option:

curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install

Create your config directory:

mkdir -p ~/.config/nix/{hosts/mbp,home,dotfiles}
cd ~/.config/nix
git init

The Flake

The flake.nix is your entry point. It declares inputs (where to get nix-darwin, home-manager, and nixpkgs) and outputs (your system configuration):

{
  description = "My nix-darwin configuration";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    nix-darwin = {
      url = "github:LnL7/nix-darwin/master";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = inputs@{ self, nixpkgs, nix-darwin, home-manager }:
    {
      darwinConfigurations."mbp" = nix-darwin.lib.darwinSystem {
        system = "aarch64-darwin";
        modules = [
          ./hosts/mbp/configuration.nix
          home-manager.darwinModules.home-manager
          {
            users.users.yourname = {
              name = "yourname";
              home = "/Users/yourname";
            };
            home-manager.useGlobalPkgs = true;
            home-manager.useUserPackages = true;
            home-manager.backupFileExtension = "backup";
            home-manager.users.yourname = import ./home/home.nix;
          }
        ];
      };
    };
}

System Configuration

The hosts/mbp/configuration.nix handles system-level stuff—packages, Homebrew casks, and macOS preferences:

{ pkgs, ... }:

{
  system.primaryUser = "yourname";

  # CLI tools (replaces brew install)
  environment.systemPackages = with pkgs; [
    bat fzf delta gh lazygit lsd ripgrep tree
  ];

  # GUI apps (declarative Homebrew)
  homebrew = {
    enable = true;
    onActivation.cleanup = "zap";
    casks = [ "raycast" "slack" "linear-linear" "ghostty" ];
  };

  # macOS preferences
  system.defaults = {
    dock.autohide = true;
    finder.AppleShowAllFiles = true;
    NSGlobalDomain.KeyRepeat = 2;
  };

  # Determinate installer manages Nix itself
  nix.enable = false;

  system.stateVersion = 5;
  nixpkgs.hostPlatform = "aarch64-darwin";
}

The homebrew.onActivation.cleanup = "zap" is aggressive—it removes any cask not in your list. Great for keeping things tidy, but be aware of what you're opting into.

Home Manager

The home/home.nix handles user-level config—your shell, git, and dotfiles:

{ config, pkgs, lib, ... }:

{
  programs.zsh = {
    enable = true;
    shellAliases = {
      cat = "bat --style=header,grid";
      ls = "lsd";
      lg = "lazygit";
    };
    initContent = ''
      eval "$(fnm env --use-on-cd --shell zsh)"
      # ... rest of your shell config
    '';
  };

  programs.git = {
    enable = true;
    settings = {
      user.name = "Your Name";
      user.email = "you@example.com";
      push.autoSetupRemote = true;
    };
  };

  programs.starship.enable = true;

  home.stateVersion = "24.11";
}

Building and Activating

With your config files in place:

nix build .#darwinConfigurations.mbp.system
sudo ./result/sw/bin/darwin-rebuild switch --flake .#mbp

The first run takes a while—Nix downloads and builds everything. Subsequent rebuilds are fast since unchanged packages are cached.
If you hit errors about existing files, Nix is being cautious. You might need to:

sudo mv /etc/zshenv /etc/zshenv.before-nix-darwin

Day-to-Day Usage

Installing a new CLI tool:

  1. Add it to environment.systemPackages in your config
  2. Run darwin-rebuild switch --flake ~/.config/nix#mbp

Installing a new GUI app:

  1. Add it to homebrew.casks
  2. Run the same rebuild command

To search for packages:

nix search nixpkgs ripgrep

Or use search.nixos.org for a faster web interface.

The New Machine Test

The real payoff comes when you set up a new Mac:

# Install Nix
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install

# Clone your config
git clone git@github.com:you/nix-config.git ~/.config/nix

# Build and activate
cd ~/.config/nix
nix build .#darwinConfigurations.mbp.system
sudo ./result/sw/bin/darwin-rebuild switch --flake .#mbp

That's it. Your entire development environment, installed and configured.

Gotchas

A few things I ran into:

  • nix-darwin updates frequently: Options get renamed. When you see deprecation warnings, check the nix-darwin options search
  • Secrets stay manual: SSH keys, GPG keys, API tokens—these don't go in your repo. Transfer them separately
  • Shell needs reloading: After darwin-rebuild switch, open a new terminal to see changes

Worth It?

For me, yes. There's something satisfying about git diff-ing your system configuration.

This isn't the only way to manage a Mac setup—tools like chezmoi or plain dotfile repos work fine for many people. But if reproducibility matters to you, Nix is worth the learning curve.

The full config from this guide is on GitHub. Go fork it and make it yours.