Bootstrapping a Mac with Nix

With nix-darwin and home-manager it is possible to manage almost all of a mac configuration declaratively. So when I got my new Macbook I was pretty sure this is the way to go. Unfortunately, the bootstrap is a bit involved. These are my notes from the process, which hopefully serves as a tutorial.

Installing dependencies

We need to install some software manually before we can go full steam with configurations stored as nix files, the primary one being nix itself.

Install nix with the determinate systems nix installer, it comes with sensible defaults and a nicer uninstaller.

Unfortunately, the GUI installer installs x86_64 version of nix, so I had to use curl |sh for my aarch64 Macbook.

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

If you haven’t already, also install Xcode tools with xcode-select --install

Generating initial configurations

We are going to use nix-darwin to keep a system wide configuration, which would represent packages that are installed, configuration for packages and configuration like shell aliases, files etc.

nix-darwin has support for both classic configuration.nix tied to a nix-channel as well as flakes, I chose the latter as it allows more finer control over dependency versions1.

% nix flake init -t nix-darwin
wrote: /Users/db/code/private/config/flake.nix
# replace the hostname
% sed -i '' "s/simple/$(scutil --get LocalHostName)/" flake.nix

# Add to revision control
git init
git add flake.nix

This generates an example flake file, with some boilerplate code to get started.

With some light editing:

  1. Moved the configuration to its on own file configuration.nix, and added it to git tree2.
  2. The appropriate hostPlatform for your mac. nixpkgs.hostPlatform = "aarch64-darwin";
  3. Set the home directory path users.users.dj.home = "/Users/dj";

We end up with

flake.nix

{
  description = "System flake configuration file";

  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 }: {
    # Build darwin flake using:
    # $ darwin-rebuild build --flake .#MacBook-Pro
    darwinConfigurations."MacBook-Pro" =
      nix-darwin.lib.darwinSystem {
        modules = [ ./configuration.nix ];
      };

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

configuration.nix

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

{
  # List packages installed in system profile. To search by name, run:
  # $ nix-env -qaP | grep wget
  environment.systemPackages = with pkgs; [
  ];

  users.users.dj.home = "/Users/dj";
  # 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;

  # Used for backwards compatibility, please read the changelog before changing.
  # $ darwin-rebuild changelog
  system.stateVersion = 3;

  nix.configureBuildUsers = true;

  # The platform the configuration will be used on.
  nixpkgs.hostPlatform = "aarch64-darwin";
}

Its time to bootstrap the system with nix-darwin!

% nix run nix-darwin -- switch --flake ~/.config/nix-darwin
building the system configuration...
[1/38/42 built, 227 copied (1406.7/1407.6 MiB), 237.3 MiB DL] building darwin-uninstaller (fixupPhase): str
Password:
setting up /run via /etc/synthetic.conf...
user defaults...
setting up user launchd services...
setting up /Applications/Nix Apps...
setting up pam...
applying patches...
setting up /etc...
system defaults...
setting up launchd services...
creating service org.nixos.activate-system
reloading service org.nixos.nix-daemon
reloading nix-daemon...
waiting for nix-daemon
waiting for nix-daemon
configuring networking...
setting nvram variables...

During the bootstrap, nix-darwin installs the command darwin-rebuild, subsequent rebuilds should use darwin-rebuild.

Both nix-darwin and darwin-rebuild follows same semantics as nixos-rebuild, test for test activation, build for only building the configuration, switch for commit and activate etc.

Install some packages

I have a set of packages that I like to have available system-wide (for all users). Add those to environment.systemPackages in configuration.nix, which gives us:

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

{
  # List packages installed in system profile. To search by name, run:
  # $ nix-env -qaP | grep wget
  environment.systemPackages = with pkgs; [
    vim
    curl
    gitAndTools.gitFull
    mg
    mosh
  ];
...

Activate with darwin-rebuild switch --flake ~/path-to-config-directory

Home Manager

home-manager is a nix community project for managing user environments, it comes with a tone of module for configuring more day-to-day user facing programs, for e.g the git module for configuring, well git.

programs.git = {
  enable = true;
  extraConfig = {
    github.user = "<user>";
    init = { defaultBranch = "trunk"; };
    diff = { external = "${pkgs.difftastic}/bin/difft"; };
  };
};

Install home-manager with flakes,

  1. Add a flake input in the inputs section
home-manager = {
    url = "github:nix-community/home-manager";
    inputs.nixpkgs.follows = "nixpkgs-unstable";
};
  1. And add the module to the modules section
home-manager.darwinModules.home-manager {
  # `home-manager` config
  home-manager.useGlobalPkgs = true;
  home-manager.useUserPackages = true;
  home-manager.users.db = import ./home.nix;
};

I choose to keep the home configuration in a separate file, home.nix.

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

{
  home.stateVersion = "23.11";

  programs.git = {
    enable = true;
    userName = "name";
    userEmail = "mail@example.org";
    extraConfig = {
      github.user = "<user>";
      init = { defaultBranch = "trunk"; };
      diff = { external = "${pkgs.difftastic}/bin/difft"; };
    };
  };
}

Also the updated flake.nix is now

{
  description = "System flake configuration file";

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

    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };

  };

  outputs = inputs@{ self, nix-darwin, nixpkgs, home-manager, nix-homebrew
    , homebrew-core, homebrew-cask, homebrew-bundle, ... }:
    {
      # Build darwin flake using:
      # $ darwin-rebuild build --flake .#MacBook-Pro
      darwinConfigurations."MacBook-Pro" =
        nix-darwin.lib.darwinSystem {
          modules = [
            ./configuration.nix

            home-manager.darwinModules.home-manager
            {
              # `home-manager` config
              home-manager.useGlobalPkgs = true;
              home-manager.useUserPackages = true;
              home-manager.users.db = import ./home.nix;
            }

          ];
        };

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

If you get an error Error: HOME is set to "/Users/<username>" but we expect "/var/empty", make sure you have set users.users.<username>.home in configuration.nix.

Manage homebrew applications

Sadly, nix still has some catching up to do with mac compatibility, biggest gripe for me was accessing GUI apps with spotlight seems to need some workarounds. Luckily brew solves this and we can just install applications with brew, still managed by nix.

nix-darwin can declaratively manage brew packages, however we need nix-homebrew to install brew itself and manage the taps declaratively.

Grab nix-homebrew using flakes, and also add the taps itself as inputs, this maybe the most underrated flakes feature. We can pin the taps to a specific version!

inputs = {
    nix-homebrew = {
      url = "github:zhaofengli-wip/nix-homebrew";
      inputs.nixpkgs.follows = "nixpkgs-unstable";
    };

    homebrew-core = {
      url = "github:homebrew/homebrew-core";
      flake = false;
    };
    homebrew-cask = {
      url = "github:homebrew/homebrew-cask";
      flake = false;
    };
    homebrew-bundle = {
      url = "github:homebrew/homebrew-bundle";
      flake = false;
    };

.. and import the module into the system configuration.

nix-homebrew.darwinModules.nix-homebrew
{
  nix-homebrew = {
    enable = true;
    # Apple Silicon Only: Also install Homebrew under the default Intel prefix for Rosetta 2
    enableRosetta = true;
    user = "username";

    taps = {
      "homebrew/homebrew-core" = homebrew-core;
      "homebrew/homebrew-cask" = homebrew-cask;
      "homebrew/homebrew-bundle" = homebrew-bundle;
    };
    mutableTaps = false;
  };
}

The package installations are itself managed by nix-darwin, using homebrew.* options.

homebrew = {
  enable = true;
  global.autoIpdate = false;

  casks = [ "kitty" ];
};

Fin!

If you have followed through all of the above, like me, you should have a mac with almost everything configured declaratively, using nix.

Further customizations options can be found in

This setup helps me share configuration with my other machines; they are just an import away! However this bootstrapping is neither simple nor short, that’s definitly something to improve.

Final configuration files

flake.nix
{
  description = "System flake configuration file";

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

    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    nix-homebrew = { url = "github:zhaofengli-wip/nix-homebrew"; };

    homebrew-core = {
      url = "github:homebrew/homebrew-core";
      flake = false;
    };
    homebrew-cask = {
      url = "github:homebrew/homebrew-cask";
      flake = false;
    };
    homebrew-bundle = {
      url = "github:homebrew/homebrew-bundle";
      flake = false;
    };

  };

  outputs = inputs@{ self, nix-darwin, nixpkgs, home-manager, nix-homebrew
    , homebrew-core, homebrew-cask, homebrew-bundle, ... }: {
      # Build darwin flake using:
      # $ darwin-rebuild build --flake .#MacBook-Pro
      darwinConfigurations."MacBook-Pro" =
        nix-darwin.lib.darwinSystem {
          system = "aarch64-darwin";
          modules = [
            ./configuration.nix

            home-manager.darwinModules.home-manager
            {
              # `home-manager` config
              home-manager.useGlobalPkgs = true;
              home-manager.useUserPackages = true;
              home-manager.users.db = import ./home.nix;
            }

            nix-homebrew.darwinModules.nix-homebrew
            {
              nix-homebrew = {
                enable = true;
                # Apple Silicon Only: Also install Homebrew under the default Intel prefix for Rosetta 2
                enableRosetta = true;
                user = "db";

                taps = {
                  "homebrew/homebrew-core" = homebrew-core;
                  "homebrew/homebrew-cask" = homebrew-cask;
                  "homebrew/homebrew-bundle" = homebrew-bundle;
                };
                mutableTaps = false;
              };
            }

          ];
        };

      # Expose the package set, including overlays, for convenience.
      darwinPackages =
        self.darwinConfigurations."MacBook-Pro".pkgs;
    };
}
configuration.nix
{ config, lib, pkgs, ... }:

{
  # List packages installed in system profile. To search by name, run:
  # $ nix-env -qaP | grep wget
  environment.systemPackages = with pkgs; [
    vim
    curl
    gitAndTools.gitFull
    mg
    mosh
  ];

  homebrew = {
    enable = true;
    global.autoUpdate = false;

    casks = [ "kitty" ];
  };

  users.users.db.home = "/Users/db";

  # 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;

  # Used for backwards compatibility, please read the changelog before changing.
  # $ darwin-rebuild changelog
  system.stateVersion = 3;

  nix.configureBuildUsers = true;

  # The platform the configuration will be used on.
  nixpkgs.hostPlatform = "aarch64-darwin";
}
home.nix
{ config, pkgs, ... }:

{
  home.stateVersion = "23.11";
  programs.git = {
    enable = true;
    userName = "user name";
    userEmail = "email";
    extraConfig = {
      github.user = "gh_user";
      init = { defaultBranch = "trunk"; };
      diff = { external = "${pkgs.difftastic}/bin/difft"; };
    };
  };
}

Revisions

  1. 28-03-2024: Removed redundant system attribute – Thx @roberth@functional.cafe

  1. Explaining flakes or nix nuanceses are out of scope and probably out of my reach, https://nixos-and-flakes.thiscute.world/ is a better resource.↩︎

  2. For flake build system to find them, git should be aware of the files.↩︎