Today I had a few problems again with brew, which is getting on in years. So I looked for an alternative for brew and came across nixos. At first I didn't realize how nixos could help me here. But a quick look at the documentation and I realized what nix is capable of.

🤷 Why would one want to have Nix on a Mac?

There are multiple reasons:

  • Nixpkgs is the biggest and freshest open-source package repository in the world
  • It’s better than homebrew
  • Use the same package versions that i use on archlinux, debian, fedora, ...
  • Programmers and admins: Toolchain management is easily done per project with the single command nix develop. Forget about Docker annoyances for development.

In addition to that, we’re looking at nix-darwin in this article, which adds the following reasons on top:

  • Declarative configuration of all your macOS system settings
  • Installation of packages and configuration of those
  • Seamless integration into launchd for configuration of additional daemons
  • Painless management of local Linux builder VMs

This Blogpost is about two steps and provides additional info for each:

  1. Install Nix
  2. Bootstrap nix-darwin

On every new Mac, it’s just two command-line invocations if your nix-darwin configuration is already prepared.

🌱 Step 1: Installing Nix on macOS

There are multiple ways to install i chose to use Determinate System’s shell installer, which is a one-liner as described in their GitHub repository:

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

The installation just takes a minute or two. After running the command, the installer asks for the sudo password and then prints a nice explanation about what it will do with our system, which we can accept or deny: Bildschirmfoto%202024-08-20%20um%2021.41.53

It’s advisable to check if there have been any errors during the installation and if there are none, close the shell and start a new one. We don’t need to restart the system.

To test if Nix generally works, just run GNU hello or any other package:

nix run "nixpkgs#hello"
Hello, world!

Please note that this command works with or without quotes, depending on your ZSH configuration.

If you’re new to Nix, make sure you get a copy of the Nix cheat sheet. It’ll give you the best possible overview of the commands available!

🛫 Step 2: Going declarative with nix-darwin

With Nix installed, we have all the Nix shell magic (using nix develop, nix shell) at our disposal and can build and run (using nix build and nix run) random projects/packages from the internet, which is great.

However, if we imagine having to install packages using nix profile install ... on every other Mac, we’re not much better off than with classical package management. Also, this doesn’t manage our configurations and services, which is exactly what we’re used to from NixOS.

The general idea is that we want to have one big configuration file (possibly scattered over multiple files for better structure and composability) that sets our system up as we want it with one single command.

This is where nix-darwin enters the scene: As the project description says, this project aims to bring the convenience of a declarative system approach to macOS.

nix-darwin-logo.DwOHc1n3_1zSc1h

Starting from zero, we can initialize a new nix-darwin configuration file in some configuration folder:

mkdir nix-darwin-config
cd nix-darwin-config
nix flake init -t nix-darwin

This creates a flake.nix file like this:

{
  description = "Example Darwin system flake";

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

  outputs = inputs@{ self, nix-darwin, nixpkgs }:
  let
    configuration = { pkgs, ... }: {
      # List packages installed in system profile. To search by name, run:
      # $ nix-env -qaP | grep wget
      environment.systemPackages =
        [ pkgs.vim
        ];

      # Auto upgrade nix package and the daemon service.
      services.nix-daemon.enable = true;
      # nix.package = pkgs.nix;

      # Necessary for using flakes on this system.
      nix.settings.experimental-features = "nix-command flakes";

      # Create /etc/zshrc that loads the nix-darwin environment.
      programs.zsh.enable = true;  # default shell on catalina
      # programs.fish.enable = true;

      # Set Git commit hash for darwin-version.
      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.
      nixpkgs.hostPlatform = "x86_64-darwin";
    };
  in
  {
    # Build darwin flake using:
    # $ darwin-rebuild build --flake .#simple
    darwinConfigurations."simple" = nix-darwin.lib.darwinSystem {
      modules = [ configuration ];
    };

    # Expose the package set, including overlays, for convenience.
    darwinPackages = self.darwinConfigurations."simple".pkgs;
  };
}

At the beginning, we generally want to change two things here:

  1. i add my musthave packages to the environment.systemPackages Bildschirmfoto%202024-08-20%20um%2022.09.32
  2. The nixpkgs.hostPlatform setting must be aarch64-darwin on Macs with Apple Silicon CPUs. On Intel-based Macs it can be left as x86_64-darwin.
  3. The simple part at the bottom of the file in the darwinConfigurations."simple" attribute can be renamed to our hostname. This way we don’t need to provide the name explicitly when building or rebuilding the system configuration.

This is enough to start. Bootstrapping this new configuration can be done even without installing any nix-darwin-related packages with a single command:

nix run nix-darwin -- switch --flake .

The last parameter still needs to be --flake .#simple if we didn’t rename the configuration attribute at the bottom of the file.

The installation process might warn us of files that could destructively be overwritten. We need to back up or remove them (on a new Mac, I typically just delete) first.

After the remove of the existing files rerun the nix run command with some more parameters:

nix --extra-experimental-features nix-command --extra-experimental-features flakes run nix-darwin -- switch --flake .

nix-darwin is now bootstrapped on our system, which gives us the darwin-rebuild command that is similar to nixos-rebuild on NixOS hosts. We can now run darwin-rebuild switch --flake . anytime.

Additional nix-darwin goodies

The new nix-darwin config did not do much to our system. It’s just a starting point. What now?

Have a look at the nix-darwin configuration options Documentation which lists and describes all the available options. This overview is a goldmine - there is something for everyone.

Let’s have a look at a few nice examples:

Unlocking sudo via fingerprint

If we have a Mac with Touch ID, we can unlock sudo commands with our fingerprint instead of typing the password. This is of course not exclusive to nix-darwin users, but these have it particularly easy to enable it.

Simply add the following line to the new config:

security.pam.enableSudoTouchIdAuth = true;

Rebuild and apply the config using

darwin-rebuild switch --flake .

Generally, reboots aren’t necessary, but this specific setting needs a reboot.

Voila, this is how it looks in action:

Bildschirmfoto%202024-08-20%20um%2022.25.51

Setting System Defaults

nix-darwin provides configuration lines for many different macOS default settings. These can typically be altered using UI application setting dialogues or with the defaults terminal command. However, nix-darwin manages them all for us:

system.defaults = {
  dock.autohide = true;
  dock.mru-spaces = false;
  finder.AppleShowAllExtensions = true;
  finder.FXPreferredViewStyle = "clmv";
  loginwindow.LoginwindowText = "Found? Call XXX";
  screencapture.location = "~/Pictures/screenshots";
  screensaver.askForPasswordDelay = 10;
};

This example configuration snippet sets:

  • macOS dock hides automatically
  • Don’t rearrange spaces based on the most recent use
  • Finder shows all file extensions
  • Default Finder folder view is the columns view
  • The login window shows a specific text as a greeting
  • When taking screenshots, store these in a specific folder
  • Only ask for a password in the screensaver if it is running for longer than 10 seconds

Apple Silicon Macs: Compile Intel Binaries

Apple Silicon Macs can install Rosetta, which enables the system to run binaries for Intel CPUs transparently.

The installation still needs to be done manually in the terminal with this command:

softwareupdate --install-rosetta --agree-to-license

After that, we can add this line to our nix-darwin configuration and rebuild again:

nix.extraOptions = ''
  extra-platforms = x86_64-darwin aarch64-darwin
'';

Now, we can build and run binaries for both CPUs:

$ nix run "nixpkgs#legacyPackages.aarch64-darwin.hello"
Hello, world!
$ nix run "nixpkgs#legacyPackages.x86_64-darwin.hello"
Hello, world!

Although this feature is a relatively specific developer use case, it’s nice to see how easy it is to configure.

Building Linux binaries

If we want to build binaries or even full system images for GNU/Linux systems, we typically end up delegating builds to remote builders.

nix-darwin provides a neat Linux builder that runs a NixOS VM as a service in the background. It can simply be activated with one additional configuration line:

nix.linux-builder.enable = true;

It works on both Apple Silicon and Intel-based Macs.

The VM itself is bootstrapped by downloading it from the official NixOS cache. It comes with pre-installed SSH keys, which nix-darwin also handles elegantly for us on the host side.

After rebuilding the system, we can test it. With a quick dummy derivation that simply writes the output of the command uname -a into its output path, we can check that it is executed in fact on our new Linux builder:

$ nix build \
  --impure \
  --expr '(with import <nixpkgs> { system = "aarch64-linux"; }; runCommand "foo" {} "uname -a > $out")'
$ cat result
Linux localhost 6.1.72 #1-NixOS SMP Wed Jan 10 16:10:37 UTC 2024 aarch64 GNU/Linux

Wow - how does it work?

  • There is a daemon called org.nixos.linux-builder running on our system, which keeps SSH keys and disk image in /var/lib/darwin-builder
  • /etc/ssh/ssh_config.d/100-linux-builder.conf creates an SSH host-alias linux-builder
  • /etc/nix/machines contains a remote builder entry

This specific VM is also documented in the nixpkgs documentation.

📲 Updating the System

Updating the system involves two steps:

  1. Updating the Nix flake inputs
  2. Rebuilding the system
nix flake update
darwin-rebuild switch --flake .

If the configuration resides in a git repository, nix flake update --commit-lock-file can automatically commit the lock file changes.

Conclusion

Some Apple fans might like setting up a new system each time, but most of us want things to be simple and in sync.

Nix in combination with nix-darwin is an unbeatable combination - on a new Mac, we can simply perform two steps:

  1. Install Nix with one of the installers
  2. Run nix run nix-darwin -- --flake github:my-user/my-repo#my-config

…and we’re done. From Finder etc. UI settings, over preinstalled packages, to additional daemons, it’s all in there!

Previous Post Next Post