Skip to main content

Declarative macOS management with nix-darwin and home-manager

·8 mins

For the past year, I’ve been daily driving the M1 Macbook Air with 8GB of RAM and 240GB of storage. Most of my life I’ve mostly been an Apple skeptic but this laptop changed my views in some aspects.

But having just come from NixOS, I was missing that declarative mindset in being able to manage my system from a single file. Luckily, with nix-darwin and home-manager I was able to get my system mostly declarative, at least declarative enough for my needs. This also allowed to manage the Mac with my existing NixOS flake, so I could reuse existing configurations, like my zsh home-manager configuration.

Installation #

Starting from a fresh macOS installation, this is how I bootstrapped my nix-darwin configuration:

  1. Install Nix:

       curl -L https://nixos.org/nix/install | sh
    

    For this step, you could also try to use the Determinate Systems nix-installer, which I’ve read good things about, although the official installer also worked just fine.

  2. Enable flakes:

       mkdir -p ~/.config/nix
       cat <<EOF > ~/.config/nix/nix.conf
       experimental-features = nix-command flakes
       EOF
    
  3. Use nix run to run the first rebuild:

       nix run nix-darwin -- switch --flake <path_to_nix_darwin_configuration>
    
  4. Use darwin-rebuild normally:

       darwin-rebuild switch --flake <path_to_nix_darwin_configuration>
    

Declarative Homebrew package management #

One of the key reasons for using nix-darwin is not only access to the nix package manager, but being able to use Homebrew declaratively.

Homebrew is the de facto package manager for macOS. Most programs you can think are available over there, especially CLI tools but also GUI apps in the form of casks.

So we would very much like to keep access to those apps, only adding declarativeness to the workflow.

Another reason for using Homebrew is simply because nixpkgs cannot yet replace it, especially when it comes to GUI apps. Some could say that this will never happen because it’s out of scope of the nixpkgs project, which I guess is reasonable.

To use Homebrew, we first have to install it manually:

$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

After that, we can use the homebrew module from nix-darwin, like the following example:

# I'd rather not have telemetry on my package manager.
environment.variables.HOMEBREW_NO_ANALYTICS = "1";

homebrew = {
  enable = true;

  onActivation = {
    autoUpdate = true;
    cleanup = "zap";
    upgrade = true;
  };

  brews = [
    "coreutils"
    "direnv"
    "fd"
    "gcc"
    "git"
    "grep"
    "ripgrep"
    "trash"
  ];

  # Update these applicatons manually.
  # As brew would update them by unninstalling and installing the newest
  # version, it could lead to data loss.
  casks = [
    "docker"
    "emacs-mac" # Emacs fork with better macOS support
    "firefox"
    "iterm2"
    "monitorcontrol" # Brightness and volume controls for external monitors.
    "ukelele"
    "unnaturalscrollwheels" # Enable natural scrolling in the trackpad but regular scroll on an external mouse
    "utm" # Virtual Machine Manager
    "visual-studio-code"
  ];

  taps = [
    "railwaycat/emacsmacport" # emacs-mac
  ];

  masApps = {
    Tailscale = 1475387142; # App Store URL id
  };
};

One of the most important features in managing packages this way is that removing packages from the configuration above also uninstalls them, which is one of the big features I was missing from NixOS. This avoids the traditional buildup of unused and forgotten packages in the system.

You supposedly can also use this to manage App Store apps but I found that I always needed to manually install them first (so they could be registered to my account?), so it wasn’t as useful.

Other noteworthy options #

The list of options is quite extensive, here are some that I found useful:

Nix management #

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

nix = {
  package = pkgs.nix;
  gc.automatic = true;
  optimise.automatic = true;
  settings = {
    auto-optimise-store = true;
    experimental-features = [ "nix-command" "flakes" ];
  };
};

sudo with Touch ID #

Just like when macOS asks for elevated privileges, this makes sudo also work with Touch ID, which I find quite ergonomic:

security.pam.enableSudoTouchIdAuth = true;

Keyboard tweaks #

These are some keyboard tweaks like turning caps lock into ctrl and disabling press and hold for diacritics:

system.keyboard.enableKeyMapping = true;
system.keyboard.remapCapsLockToControl = true;

# Disable press and hold for diacritics.
# I want to be able to press and hold j and k
# in VSCode with vim keys to move around.
system.defaults.NSGlobalDomain.ApplePressAndHoldEnabled = false;

Simple nix-darwin flake #

Flakes are all the rage right now but they can be daunting to new users.

Here’s a simple nix-darwin flake to get you started, which also makes it easy to use home-manager:

flake.nix:

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
    nixpkgs-darwin.url = "github:NixOS/nixpkgs/nixpkgs-23.11-darwin";

    nix-darwin.url = "github:LnL7/nix-darwin/master";
    nix-darwin.inputs.nixpkgs.follows = "nixpkgs-darwin";

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

  outputs = { self, ... }@inputs:
    {
      darwinConfigurations."<hostname>" = inputs.nix-darwin.lib.darwinSystem {
        system = "aarch64-darwin";
        specialArgs = { inherit inputs self; };
        modules = [
          ./hosts/mac.nix
          inputs.home-manager.darwinModules.home-manager
          {
            home-manager.useGlobalPkgs = true;
            home-manager.useUserPackages = true;
          }
        ];
      };
    };
}

Where ./hosts/mac.nix would be your nix-darwin configuration file, with the previous snippets.

home-manager #

Now, we can use home-manager in our configuration.

Here are some examples:

# Import existing home-manager configuration
imports = [ "${self}/profiles/home/zsh.nix" ];

# Required by home-manager.
users.users.john.home = "/Users/john";

home-manager.users.john = {
    programs = {
      zsh = {
        enable = true;
        autocd = true;
        defaultKeymap = "emacs";

        enableCompletion = true;
        enableVteIntegration = true;
        enableAutosuggestions = true;
        syntaxHighlighting.enable = true;

        history = {
          expireDuplicatesFirst = true;
          extended = true;
          ignoreDups = true;
        };
      };

      direnv = {
        enable = true;
        nix-direnv.enable = true;
      };

      dircolors.enable = true;
      fzf.enable = true;
      starship.enable = true;
      zoxide.enable = true;

      initExtra = ''
        # Enable iTerm2 shell integration.
        test -e "~/.iterm2_shell_integration.zsh" && source "~/.iterm2_shell_integration.zsh"
      '';
    };

  home.stateVersion = "23.11";
};

This allows you to consolidate your dispersed dotfiles into a single source of truth, the nix-darwin config file. To find options for other programs home-manager supports, I found the (unofficial?) home manager options search quite useful.

I believe you can also use the home.file options to write to arbitrary files in your home directory.

Closing remarks #

This experience turned the macOS experience much more tolerable.

And it really is true: Apple Silicon is game-changing. Compared to the ThinkPads I was used to, the MacBook had a great screen (brightness, color and HiDPI, although I would skip the glossy finish), great speakers, was way faster, and didn’t heat up even though it’s passively cooled, which also means that there’s no fan noise.

I managed to buy this one used for 550€, which is probably the best deal for a laptop in mid-2024 for most people. But despite all of that, there’s a dealbreaker: the keyboard was starting to give me symptoms of RSI, just like this anecdote. My experience was pretty much the same and now that I’ve stopped using it, my hands feel much better. I really can’t justify using the laptop if that has implications on my physical health.

Also, even though I really tried, I didn’t find macOS window management very ergonomic. Full-screen apps being workspaces, the workspaces shuffling around chaotically (most recently used? but only if you click the app from the dock?), having to swipe around looking for what I wanted or always having to go to Mission Control. I found it really confusing, perhaps because I’m not the kind of user to always be using a single window at once.

I’m aware that there are tiling window managers for macOS, some that even work without disable System Integrity Protection, but by that point: why even use macOS if you’re not using like it was designed?

But if you dislike macOS but tolerate the keyboard, then an Apple Silicon Macbook is probably one of the greatest Linux laptops I’ve ever used, but I’ll leave that to a future blog post.

Finally, Apple has some questionable practices, although they’re mostly hardware-related instead of software-related, which I do prefer, if I had to pick my poison from the selection of behaviours of big tech companies. What I mean by this is that, until now, Apple’s software although proprietary, (seems to) respect user privacy, doesn’t fill available screen space with ads and doesn’t force updates on its users while they’re working.

The reason for this seems to be that Apple’s business model is selling expensive hardware, so they don’t need to monetize their software as much as other companies. This also means that, if you’re buying new, you’ll have to pay 200€ for 8GB RAM upgrade increments and 200€ for a 240GB SSD storage increment, because they’re both soldered in. You can avoid falling into the scam by only buying the base models, which will make the most out of your money, or by buying used. The problem is: I also found 8GB way too restricting, I’d really recommend going for 16GB minimum. I managed to make the experience a bit better by using Auto Tab Discard in Firefox but I still seemed to be under intense memory pressure all the time.

Besides that, I felt no need to buy anything more recent than the M1 MacBook Air. Not only that, it seems that the M1 MacBook Air is better than the M2 MacBook Air in some aspects, like having greater memory bandwidth and faster storage, because it has 2 NAND chips instead of just 1.

Bottom line is that despite Apple arguably running a hardware upgrade pricing scam, trying to lock you in to the walled garden/ecosystem and muffling down right to repair (while making their hardware harder to repair, especially by soldering in SSDs that do have a very finite lifespan), I find their laptops are really good, if you can tolerate the keyboards.

References #