← Home

Using nix-darwin

2 May, 2025

As I've recently switched to macOS, I decided to configure my system using nix-darwin. I've played around with Nix and NixOS before, and I'm in no way an expert, but I like the idea of declaratively defining my system config. It makes it easier to revert things back to the way they were at a certain point, and to track what sort of changes I've made over the years, aside from quicker setup when I need to start from scratch again.

For my dotfiles (zsh, wezterm, tmux, etc...) I went with simply symlinking things (with nix-darwin's activationScripts) rather than having it managed in a more Nix-y way. I'd like to keep the mental load of learning things that I don't interact with every day to a minimum, because I'm bound to forget, and as much as I enjoy configuring my tools, my aim has always been to do as much as necessary to reach a point where I touch my config less and less (the last time I made a change to my emacs config was 2022, but I took the opportunity to overhaul it now as I've migrated all my config to a single repo).

My system config and other dotfiles are here: https://github.com/beshrkayali/system.

Using Nix-Darwin

It was surprisingly easy to set things up with nix-darwin.

Since I use Homebrew, and nix-darwin doesn't manage installing it, I had to install that manually.

First, installing Nix itself, the README recommended using either Nix (via Nix installer from Determinate Systems) or Lix (which I've never heard of before). The README also recommended installing the vanilla upstream Nix (by answering no to installing Determinate Nix).

I created a flake.nix and split my config into a separate fdarwin.nix module, as I've seen many people do.

The Config

Configurations docs are available online on nix-darwin's github pages, or locally by running the darwin-help command.

One of my favorite aspects of nix-darwin is the ability to declaratively set macOS system preferences:

# System defaults
system.defaults = {
  screencapture.location = "~/Pictures/screenshots";
  SoftwareUpdate.AutomaticallyInstallMacOSUpdates = true;

  NSGlobalDomain = {
    AppleKeyboardUIMode = 3;
  };

  dock = {
    autohide = true;
    orientation = "left";
    show-process-indicators = false;
    show-recents = false;
    static-only = true;
    tilesize = 32;
  };

  finder = {
    AppleShowAllExtensions = true;
    ShowPathbar = true;
    FXEnableExtensionChangeWarning = false;
    FXPreferredViewStyle = "clmv";
    ShowStatusBar = true;
  };
};

I've also enabled the nifty Touch ID auth for sudo commands

security.pam.services.sudo_local.touchIdAuth = true;

Most of my tools are installed via Homebrew (with nix-darwin's Homebrew integration), but I still install a few essential core packages via environment.systemPackage:

environment.systemPackages = with pkgs; [
  wget
  curl
  git
  htop
  ripgrep
];

and for Homebrew (I'll probably change what's installed frequently, check on github for latest):

homebrew = {
  enable = true;
  onActivation = {
    autoUpdate = true;
    cleanup = "zap";
  };

  brews = [ /* CLI tools */ ];
  casks = [ /* GUI applications */ ];
  masApps = { /* App Store applications */ };
};

The cleanup = "zap" setting is to remove any manually installed Homebrew packages not defined in my config, keeping things clean and predictable.

I've also configured automatic garbage collection cleanups to run weekly.

nix.gc = {
  automatic = true;
  interval = {
    Hour = 3;
    Minute = 15;
    Weekday = 7;  # Sunday
  };
  options = "--delete-older-than 7d";
};

Rather than managing symlinks manually, I used nix-darwin's activationScripts. Ironically, this might be the most complicated part of my config. But it's straightforward. We first set up variables for the home directory and repository path, then define an array of symlinks as source-target pairs (covering configuration files for nix-darwin itself, Emacs, and other dotfiles). The mkSymlinkCmd function generates shell commands for each symlink to handle various edge cases, like removing existing symlinks and creating timestamped backups of existing files before replacing them. This function is then mapped across all symlinks and joins the results.

  system.activationScripts.extraActivation.text = let
    homeDir = config.users.users.beshr.home;
    repoPath = "${homeDir}/src/system";

    # Define symlinks as source -> target pairs
    symlinks = [
      # Format: [source target]
      # - Nix Darwin
      ["${repoPath}/nix-darwin" "/etc/nix-darwin"]
      # - Emacs
      ["${repoPath}/emacs" "${homeDir}/.emacs.d"]
      # - Other dotfiles
      ["${repoPath}/wezterm.lua" "${homeDir}/.wezterm.lua"]
      ["${repoPath}/zshrc.zsh" "${homeDir}/.zshrc"]
      ["${repoPath}/tmux.conf" "${homeDir}/.tmux.conf"]
      ["${repoPath}/gitconfig" "${homeDir}/.gitconfig"]
    ];

    # Function to generate symlink commands
    mkSymlinkCmd = link: let
      source = builtins.elemAt link 0;
      target = builtins.elemAt link 1;
    in ''
      # For ${source} -> ${target}
      if [ -L "${target}" ]; then
        echo "Removing existing symlink at ${target}..."
        rm "${target}"
      elif [ -e "${target}" ]; then
        echo "Backing up existing file at ${target}..."
        mv "${target}" "${target}.backup-$(date +%Y%m%d-%H%M%S)"
      fi
      echo "Creating symlink: ${source} -> ${target}"
      mkdir -p "$(dirname "${target}")"
      ln -sfn "${source}" "${target}"
    '';

  in ''
    echo "Setting up symlinks..."
    ${builtins.concatStringsSep "\n" (map mkSymlinkCmd symlinks)}
    echo "Symlink setup complete!"
  '';

I'm quite satisfied with this hybrid approach. I benefit from Nix's declarative system-wide configuration and package management, while maintaining flexibility with my dotfiles that remain easily editable outside the Nix ecosystem.