No Longer All-in on Homebrew: Managing My Mac with nix-darwin

Saturday, May 10, 2025 / , , , ,
Simplified ChineseEnglish
Summary:
After managing all my software with Homebrew for several years, problems with versions and dependencies kept increasing, so I introduced nix-darwin: command-line tools go to Nix, GUI applications remain with Homebrew, and language environments are still managed by mise.

Since 2021, almost all the software on my Mac has been installed with Homebrew: command-line tools via formulae, GUI applications via casks, and finally everything recorded in a Brewfile. This approach really worked well; when switching computers, a single brew bundle command can restore everything, and deleting software is also fairly clean.

But after a few years of use, the problems kept multiplying.

Problems with Homebrew

First, versions. Homebrew's formulae generally provide only the latest version. Apart from a few versioned formulae such as openjdk@17 and python@3.12, installing a specific older version is quite troublesome. And once brew upgrade is run, everything gets upgraded along with it; when a tool occasionally breaks after an upgrade, rolling it back is not very easy either.

Next, dependencies. When Homebrew installs a formula, it installs its dependencies as well, and those dependencies get mixed together with the things I installed myself. What gave me the biggest headache was Rust. My Rust is installed with mise, but as soon as a formula depends on Rust, Homebrew installs its own Rust as well, so /opt/homebrew/bin also gains another set of cargo and rustc. Which of the two Rust installations gets found first depends on the order of PATH, and as a result sometimes cargo build is not the version I think it is.

For this reason I even wrote a function specifically to check whether the current cargo comes from Homebrew.

Shell
# check if using correct cargo
function check_cargo() {
  cargo_path=$(which cargo 2>/dev/null)
  if [[ -z "$cargo_path" ]]; then
    echo "Cargo is not installed or not in PATH."
    return 1
  fi
  if [[ "$cargo_path" == "/opt/homebrew/bin/cargo" ]]; then
    echo "$cargo_path cargo path error"
  fi
}

Python had a similar situation. My Brewfile contained several Python packages, such as numpy, cffi, cryptography—I had never installed them myself; they were pulled in as dependencies by other formulae, and along the way it also brought in python@3.12, which then got mixed together with the Python installed by mise.

Finally, the Brewfile itself. Although I kept using it, it was actually a record made after the fact: after installing things, I would run brew bundle dump to export the current state. And what got dumped was mixed with a large number of formulae that I had not installed intentionally but that had come in only as dependencies. The Brewfile grew longer and longer, until eventually it had nearly two hundred formulae, of which maybe only half were ones I had installed deliberately.

Why Nix

I used NixOS for a while before and always liked its declarative way of managing a system. Everything is written in configuration files: if you want something, you write it down; if you don't want it, you delete it; then run rebuild, and the system becomes exactly as described in the configuration. My insistence on maintaining a Brewfile was actually for this declarative feeling, but the Brewfile did not go far enough.

Nix packages are all installed in /nix/store, where each package has its own independent path, and dependencies are isolated separately. So even if a tool depends on Rust at build time, that Rust exists only within its own dependencies and will not appear in my PATH, much less clash with the Rust installed by mise. Moreover, nixpkgs has a prebuilt binary cache, so most packages install as fast as with Homebrew.

So on a Mac, if you want to use Nix to manage the system, you need nix-darwin. It is essentially the NixOS configuration module for macOS: besides installing software, it can manage system settings, launchd services, environment variables, and even has a Homebrew module that lets you declare what Homebrew should install using Nix configuration.

Division of Responsibilities

But Nix is not suitable for everything. GUI applications in nixpkgs are not comprehensive; even when they exist, many are not as easy to use as the official dmg, and Homebrew casks already do this very well. Also, for programming language runtimes, I want each project to be able to use a different version, and handing that to Nix is too heavy.

So in the end I divided it like this.

What it handles Managed by
System settings, command-line tools, launchd services nix-darwin
GUI applications (casks) Homebrew, declared by nix-darwin
Programming languages and their toolchains mise
Mermaid diagram

Installation

First install Nix and enable flakes. Then, following the nix-darwin instructions, under /etc/nix-darwin use the template to generate a flake.nix.

Shell
sudo mkdir -p /etc/nix-darwin
sudo chown $(id -nu):$(id -ng) /etc/nix-darwin
cd /etc/nix-darwin

nix flake init -t nix-darwin/master
sed -i '' "s/simple/$(scutil --get LocalHostName)/" flake.nix

On the first installation, the system does not yet have the darwin-rebuild command, so you need to use nix run to run it once.

Shell
nix run nix-darwin/master#darwin-rebuild -- switch

After that, each time you change the configuration, just run the command below for it to take effect.

Shell
darwin-rebuild switch

Configuration

The template-generated flake.nix puts all configuration inside a function named configuration, and I just kept adding to it. Below is roughly what my current configuration looks like, with some software omitted.

NIX
{
  description = "Brian's nix-darwin system";

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

  outputs = inputs@{ self, nix-darwin, nixpkgs }:
  let
    configuration = { pkgs, ... }: {
      nixpkgs.hostPlatform = "aarch64-darwin";
      nixpkgs.config.allowUnfree = true;
      nix.settings.experimental-features = "nix-command flakes";

      environment.systemPackages = [
        # 常用命令行工具
        pkgs.bat
        pkgs.eza
        pkgs.fd
        pkgs.fzf
        pkgs.ripgrep
        pkgs.jq
        pkgs.btop
        pkgs.yazi
        pkgs.zellij
        pkgs.zoxide
        pkgs.starship
        pkgs.gh
        pkgs.helix
        pkgs.neovim
        # 开发工具
        pkgs.cmake
        pkgs.ninja
        pkgs.just
        pkgs.lazygit
        pkgs.hyperfine
        pkgs.tokei
        # 网络工具
        pkgs.mtr
        pkgs.nmap
        pkgs.iperf3
        pkgs.doggo
        pkgs.nexttrace
        # 语言环境交给 mise
        pkgs.mise
      ];

      homebrew = {
        enable = true;
        taps = [ "xxxbrian/tap" ];
        brews = [
          "openssl@3"
          "sqlite"
          "readline"
          "libyaml"
          "pkgconf"
          "icu4c"
          "zlib"
        ];
        casks = [
          "1password"
          "ghostty"
          "google-chrome"
          "orbstack"
          "raycast"
          "surge"
          "telegram"
          "visual-studio-code"
          "zed"
        ];
        onActivation.cleanup = "zap";
      };

      security.pam.services.sudo_local.touchIdAuth = true;

      system.defaults = {
        NSGlobalDomain = {
          ApplePressAndHoldEnabled = false;
          KeyRepeat = 2;
          InitialKeyRepeat = 15;
        };
        dock = {
          autohide = false;
          show-recents = false;
          tilesize = 58;
        };
      };

      system.configurationRevision = self.rev or self.dirtyRev or null;
      system.stateVersion = 6;
    };
  in
  {
    darwinConfigurations."brian-mbp-m1max" = nix-darwin.lib.darwinSystem {
      modules = [ configuration ];
    };
  };
}

Command-Line Tools

environment.systemPackages lists the command-line tools to install; their names are basically the same as in Homebrew, and if they differ you can go to search.nixos.org and search. Once installed, they all appear in /run/current-system/sw/bin.

Sometimes the package in nixpkgs is not quite what I want, and it can be adjusted with an overlay. For example, nexttrace needs to link against libpcap in my setup, so I can override its build inputs like this.

NIX
nixpkgs.overlays = [
  (final: prev: {
    nexttrace = prev.nexttrace.overrideAttrs (old: {
      buildInputs = (old.buildInputs or [ ]) ++ [ prev.libpcap ];
    });
  })
];

Homebrew

nix-darwin does not install Homebrew for you, so Homebrew itself still needs to be installed in the original way. After that, nix-darwin will use homebrew section of the configuration to generate a Brewfile, and then on every switch it calls brew bundle to execute it.

In other words, the Brewfile is now generated from the configuration rather than dumped from my machine. This way it contains only the casks I explicitly wrote down, with no pile of dependencies mixed in.

onActivation.cleanup = "zap" is the most important item, I think. Once it is set, on every switch, any formula and cask not in the configuration will be uninstalled, and it uses --zap, which also cleans up anything the app left in ~/Library. So to remove an application, just delete it from the configuration and then switch once.

But precisely because of this, before the first switch you must list all casks, otherwise any app not written in will be deleted along with its data. At the time, I first used brew list --cask to list all casks and only dared run it after checking them one by one.

brews I kept only a few libraries, such as openssl@3, readline, sqlite, zlib. Because some things need to be compiled locally, such as compiling certain language versions with mise, or building packages with C extensions; on macOS these build scripts look for these libraries in Homebrew's paths by default, so leaving them in Homebrew is actually the least troublesome.

System Settings

system.defaults can set many things that would otherwise require clicking in System Settings or using defaults write to change, such as key repeat speed and Dock size and behavior. I wrote in all the items I am used to, so when switching to another computer, these settings can come along too.

security.pam.services.sudo_local.touchIdAuth lets sudo be authenticated with Touch ID; once enabled, typing sudo in the terminal no longer requires typing a password—just press your fingerprint.

launchd Services

Background services that I used to run with brew services, such as aria2, can now be declared directly in nix-darwin as a user launchd service.

NIX
environment.userLaunchAgents.aria2 = {
  enable = true;
  target = "org.nix-darwin.aria2.plist";
  text = ''
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
    <dict>
      <key>Label</key>
      <string>org.nix-darwin.aria2</string>
      <key>ProgramArguments</key>
      <array>
        <string>${pkgs.aria2}/bin/aria2c</string>
      </array>
      <key>RunAtLoad</key>
      <true/>
      <key>KeepAlive</key>
      <true/>
    </dict>
    </plist>
  '';
};

${pkgs.aria2} will be replaced with aria2's actual path in /nix/store, so the service always points to the version in the configuration.

mise

I handed all language versions over to mise last year, and this time it is unchanged. The only difference is that mise itself used to be installed with Homebrew and is now installed by Nix: the line above in systemPackages, namely pkgs.mise. ~/.config/mise/config.toml is still the same file; Nix does not touch it.

So now the boundaries between the three are very clear. Nix manages the system and command-line tools, Homebrew manages GUI applications, and mise manages languages. Because the command-line tools have all been switched to Nix, Homebrew will no longer secretly install a Rust as a dependency, and which cargo will always point to the one managed by mise.

Migration

I did the whole migration little by little.

  1. First, I used brew leaves to list all formulae I had installed intentionally, found the corresponding packages in nixpkgs, and wrote them into environment.systemPackages.
  2. Then I used brew list --cask to list all casks and wrote them as-is into homebrew.casks.
  3. After confirming there were no mistakes, I turned on onActivation.cleanup = "zap", ran darwin-rebuild switch, and let it clean up all formulae not in the configuration.

After the last step ran, brew leaves contained only those few libraries, and all command-line tools had been switched to the Nix versions under /run/current-system/sw/bin.

Now

Now if I want to install a command-line tool, I just add a line in systemPackages, and if I want to install an application, I add a line in casks, then run darwin-rebuild switch. Deleting is the same: delete that line and then switch and it's done. What is installed on the whole computer and how the system is configured are all written in /etc/nix-darwin/flake.nix, a single file.

Moreover, every time switch generates a new system version, and if I break something, I can use darwin-rebuild switch --rollback to roll back to the previous version—something I would never have dared imagine with Homebrew.

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...