No Longer All-in on Homebrew: Managing My Mac with nix-darwin
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.
# 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 |
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.
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.
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.
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.
{
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.
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.
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.
- First, I used
brew leavesto list all formulae I had installed intentionally, found the corresponding packages in nixpkgs, and wrote them intoenvironment.systemPackages. - Then I used
brew list --caskto list all casks and wrote them as-is intohomebrew.casks. - After confirming there were no mistakes, I turned on
onActivation.cleanup = "zap", randarwin-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.