Package management on macOS with nix-darwin
I think Nix is really cool. Nix the package
manager and functional configuration language is most often associated with NixOS the Linux distro,
but nix-darwin
makes it almost as easy to declaratively configure macOS as it is to configure
NixOS installations. Even if you’ll still relying on Homebrew for package management and never touch
nixpkgs, I’d say that Nix with nix-darwin
provides the best way to manage packages and system
configuration on macOS.
Unfortunately, the resources for getting started and integrating different parts of the Nix ecosystem are not particularly approachable for beginners. When I started out I would often use GitHub’s code search to trawl through other people’s configs and try different snippets until I found what actually worked. Inspired by Arne Bahlo’s Emacs from Scratch series, I wanted to create a guide to help folks get started with Nix on macOS from scratch, step by step.
Throughout this series we’ll create a declarative system configuration with Nix where you can manage anything from your shell aliases to what VSCode extensions you have installed to running daemons with launchd. We’ll build up to this incrementally: by the end of this post, you’ll have Nix installed on your system and be able to declaratively install system-level packages from either Nixpkgs or Homebrew.
Installing Nix
I recommend using the Determinate Systems Nix installer. They have a command-line utility and also recently came out with a graphical installer if you’d prefer that.
Setting up nix-darwin
nix-darwin
is a Nix library that makes it easy to configure
macOS through Nix.
Nix is a programming language, and Nix configurations are programs. All programs need an entrypoint, we’ll be using a flakeSince this series will focus on system configuration rather than development environments, we’ll only be creating this one flake and won’t cover flakes in-depth. If want to read more about flakes, feel free to check out Julia Evans’s blog post on flakes and the Zero to Nix wiki page. to provide the entrypoint to our configuration.
Below is our minimal flake that calls nix-darwin
. Make sure to replace $USER
with your username
and $HOSTNAME
with your system’s hostname.
You can place this flake in any directory you’d like. For the purposes of this series, we’ll assume
that the flake lives at ~/.config/nix/flake.nix
.
# ~/.config/nix/flake.nix
{
description = "My system configuration";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
nix-darwin = {
url = "github:LnL7/nix-darwin";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = inputs@{ self, nix-darwin, nixpkgs }:
let
configuration = {pkgs, ... }: {
services.nix-daemon.enable = true;
# Necessary for using flakes on this system.
nix.settings.experimental-features = "nix-command flakes";
system.configurationRevision = self.rev or self.dirtyRev or null;
# Used for backwards compatibility. please read the changelog
# before changing: `darwin-rebuild changelog`.
system.stateVersion = 4;
# The platform the configuration will be used on.
# If you're on an Intel system, replace with "x86_64-darwin"
nixpkgs.hostPlatform = "aarch64-darwin";
# Declare the user that will be running `nix-darwin`.
users.users.$USER = {
name = "$USER";
home = "/Users/$USER";
};
# Create /etc/zshrc that loads the nix-darwin environment.
programs.zsh.enable = true;
environment.systemPackages = [ ];
};
in
{
darwinConfigurations."$HOSTNAME" = nix-darwin.lib.darwinSystem {
modules = [
configuration
];
};
};
}
Activating our nix-darwin
config
One of the stranger footguns when using Nix flakes is that all files referenced by a flake must be
checked into source control. This means that you’ll need to have git
installed before we set up
our Nix environmentIf you’re on a brand new machine, the first time you run git
in the terminal you should be
prompted to install Xcode Command Line Tools, which includes git
.. Files just need to be staged to be accessible, not committed, so git add
is sufficient until you want to back up your config to a remote repository.
$ cd ~/.config/nix
$ git init
$ git add flake.nix
Once all this is set up, we can run nix-darwin
to activate our configuration:
$ nix run nix-darwin --extra-experimental-features nix-command --extra-experimental-features flakes -- switch --flake ~/.config/nix
nix-darwin
requires sudo
, so you’ll be prompted for your password. Nix may error out if there
are files that already exist at paths that it’s trying to replace. Feel free to either rm
these or
mv
them to a backup location, and then re-run the line above.
Once the command succeeds, open up a new terminal window to pick up the new zsh environment and
confirm that darwin-rebuild
is installed on your path:
$ darwin-rebuild --help
darwin-rebuild [--help] {edit | switch | activate | build | check | changelog}
[--list-generations] [{--profile-name | -p} name] [--rollback]
[{--switch-generation | -G} generation] [--verbose...] [-v...]
[-Q] [{--max-jobs | -j} number] [--cores number] [--dry-run]
[--keep-going] [-k] [--keep-failed] [-K] [--fallback] [--show-trace]
[-I path] [--option name value] [--arg name value] [--argstr name value]
[--flake flake] [--update-input input flake] [--impure] [--recreate-lock-file]
[--no-update-lock-file] [--refresh] ...
Congrats on setting up nix-darwin
! Our configuration is active, but it doesn’t do anything useful
yet. Let’s change that.
Installing your first Nix package
It’s time to install our first package from the nixpkgs repository. Update the list of
systemPackages
declared in flake.nix
:
environment.systemPackages = [ pkgs.neofetch pkgs.vim ];
Here, we’re setting the attribute environment.systemPackages
to a
list. It’s important to point out that
lists in Nix are space-separated rather than comma-separated like most other languages.
pkgs
refers to nixpkgs
, the standard repository for finding packages to be installed with
NixWhile it’s not necessary to fully understand this right now, the configuration
value that
we’re defining is a Nix module. The
nix-darwin.lib.darwinSystem
function that’s called at the bottom of the file is responsible
for passing nixpkgs
through to configuration
with the name pkgs
. We’ll dive deeper into
Nix modules in a later post.. Both neofetch
and vim
are derivations within nixpkgs.
To rebuild our Nix config, we don’t have to use the super long nix run
command from above anymore
since nix-darwin
added the darwin-rebuild
command to our PATH
. From now on, we just need
to run:
$ darwin-rebuild switch --flake ~/.config/nix
Once this runs successfully, we now have a new command in our PATH
:
$ neofetch -L
c.'
,xNMM.
.OMMMMo
lMM"
.;loddo:. .olloddol;.
cKMMMMMMMMMMNWMMMMMMMMMM0:
.KMMMMMMMMMMMMMMMMMMMMMMMWd.
XMMMMMMMMMMMMMMMMMMMMMMMX.
;MMMMMMMMMMMMMMMMMMMMMMMM:
:MMMMMMMMMMMMMMMMMMMMMMMM:
.MMMMMMMMMMMMMMMMMMMMMMMMX.
kMMMMMMMMMMMMMMMMMMMMMMMMWd.
'XMMMMMMMMMMMMMMMMMMMMMMMMMMk
'XMMMMMMMMMMMMMMMMMMMMMMMMK.
kMMMMMMMMMMMMMMMMMMMMMMd
;KMMMMMMMWXXWMMMMMMMk.
"cooc*" "*coo'"
Now we’re really cooking!
Searching Nixpkgs
Check out nixpkgs search to find other packages you might want to install.
Installing packages from Homebrew
Nixpkgs is expansive, but some programs are still only available from
Homebrew. nix-darwin
provides what I think is the best interface for Homebrew
formulae, casks, and even Mac App Store appsWhile we won’t be installing any App Store apps in this post, you can check out the
description in the nix-darwin
documentation for more
information.. Let’s add this right under
environment.systemPackages
:
homebrew = {
enable = true;
# onActivation.cleanup = "uninstall";
taps = [];
brews = [ "cowsay" ];
casks = [];
};
Running darwin-rebuild switch --flake ~/.config/nix
again will install the Homebrew formula
specified in the brews
list. Try it out:
$ cowsay "homebrew and nix can be best friends"
______________________________________
< homebrew and nix can be best friends >
--------------------------------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
If you’re on a Mac where you’ve been using Homebrew for a while, you can run brew list
and brew list --cask
to list your installed formulae and casks. Once you’ve added every package you want to carry
over to the corresponding lists in your Nix config, uncomment onActivation.cleanup = "uninstall"
. Your homebrew config in nix-darwin
is now declarative: only the packages specified
in your flake.nix
will be installed, and if you ever remove a package from the lists here it will
be uninstalled the next time you reload with darwin-rebuild switch
.
Nixpkgs vs Homebrew
While nix-darwin
makes it easy to install packages with Homebrew, I’d recommend trying to find the
corresponding derivations within Nixpkgs when possible rather than relying solely on Homebrew
formulae. As the series goes on we’ll see how native Nix derivations are easier work with in Nix.
Additional configuration
You’ve probably gotten tired of entering your password everytime you reload your config. Luckily,
there’s a one-liner to enable Touch ID for sudo
, which you can put at the end of your
configuration:
# ...
let
configuration = {pkgs, ... }: {
# ...
security.pam.enableSudoTouchIdAuth = true;
};
in
# ...
All of nix-darwin
’s configuration options are worth exploring — we’ll go more in-depth into some
of them in future installments of this series, but in case you’re curious, the you can explore all
the configuration options and start making your
config your own!
If you’d like to see the full file that we’ve built up over the course of this post, you can find it here.
Now that we have a handle on our system configuration, in the next post we’ll set up
home-manager
and use it manage dotfiles and other
program configuration.
Until next time!