Brandon
  • Home
  • Articles
  • Notes

On this page

  • What is Nix and why use it?
  • Install Nix (macOS)
  • Minimal flake with nix-darwin + Home Manager
  • Install a package → add configuration → overlay it
  • Homebrew: when it’s useful (and what it isn’t)
  • Out-of-store symlinks: edit configs without rebuilding
  • direnv: per-folder shells with flakes
  • Putting it together: a pragmatic workflow
  • Common commands

Nix on macOS: flakes, Home Manager, nix-darwin, overlays

A practical guide to reproducible dev setups on macOS

nix
macos

Having a consistent development environment boosts focus and cuts yak-shaving. Nix lets us declare our tools and configs in code, pin exact versions, and reproduce the same setup on any machine.

This page is a practical end-to-end on macOS using:

  • Nix + flakes for pinning inputs
  • Home Manager for user-level configuration
  • nix-darwin for macOS system integration
  • Overlays to pin or swap package versions (Neovim example)
  • Homebrew when it’s the right tool (GUI apps, quick starts)
  • Out-of-store symlinks so you can iterate on configs without rebuilding
  • direnv for per-folder, automatic Nix shells
NoteRepo + related
  • Dotfiles repo: https://github.com/bswrundquist/dotfiles/tree/main
  • See also: Ansible-based dotfiles

What is Nix and why use it?

At its core, Nix is a package manager that builds software in a content-addressed store. That sounds fancy, but the benefits are simple and compelling:

  • Reproducible: pin exact revisions of inputs; rebuild anywhere, get the same outputs.
  • Isolated: multiple versions can happily coexist; rollbacks are trivial.
  • Declarative: describe what you want; let Nix figure out how to build it.

On macOS, Nix integrates great with:

  • Home Manager: user dotfiles and programs
  • nix-darwin: macOS system-level config (launchd services, defaults, shells)
  • Flakes: a modern way to pin inputs and define outputs (systems, dev shells)

Install Nix (macOS)

Use the official installer:

sh <(curl -L https://nixos.org/nix/install)

Enable flakes and the newer CLI. You can set this once in your system config later, but to get started immediately:

mkdir -p ~/.config/nix
printf "experimental-features = nix-command flakes\n" >> ~/.config/nix/nix.conf

Minimal flake with nix-darwin + Home Manager

Here’s a compact but useful flake.nix that:

  • Pins nixpkgs and home-manager
  • Uses nix-darwin to manage the mac
  • Enables Home Manager as a nix-darwin module
  • Installs a few packages and sets mac + user settings
{
  description = "macOS with nix-darwin + Home Manager";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
    nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";

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

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

  outputs = { self, nixpkgs, nixpkgs-unstable, darwin, home-manager, ... }:
  let
    system = "aarch64-darwin"; # Apple Silicon; use "x86_64-darwin" for Intel
    pkgs = import nixpkgs { inherit system; };

    # Example overlay that sources one package (neovim) from a different, pinned nixpkgs
    neovimOverlay = final: prev: {
      neovim = nixpkgs-unstable.legacyPackages.${system}.neovim;
    };
  in {
    darwinConfigurations."brandon-mac" = darwin.lib.darwinSystem {
      inherit system;
      modules = [
        # Base system configuration
        ({ pkgs, ... }: {
          nix.settings.experimental-features = [ "nix-command" "flakes" ];
          nixpkgs.overlays = [ neovimOverlay ];

          environment.systemPackages = with pkgs; [
            git
            neovim
            direnv
          ];

          programs.zsh.enable = true;

          # Homebrew is handy for GUI apps, but versions are managed by brew itself
          homebrew.enable = true;
          homebrew.casks = [
            "visual-studio-code"
            "raycast"
          ];
        })

        # Home Manager as a nix-darwin module
        home-manager.darwinModules.home-manager
        {
          home-manager.useGlobalPkgs = true;
          home-manager.users.brandon = { pkgs, config, ... }: {
            home.stateVersion = "24.05";
            programs.home-manager.enable = true;

            # User packages
            home.packages = with pkgs; [
              fd
              ripgrep
            ];

            # direnv + nix-direnv for per-folder flakes
            programs.direnv.enable = true;
            programs.direnv.nix-direnv.enable = true;

            # Link a config without requiring rebuilds on every edit
            # (edit files in-place; Home Manager points to your working tree)
            xdg.configFile."nvim".source =
              config.lib.file.mkOutOfStoreSymlink "/Users/brandon/dev/dotfiles/config/nvim";
          };
        }
      ];
    };
  };
}

Bootstrap commands (run from your flake directory):

# First time
darwin-rebuild switch --flake .#brandon-mac

# Subsequent updates
darwin-rebuild switch --flake .#brandon-mac

This single command applies both the nix-darwin system bits and your Home Manager user config.

Install a package → add configuration → overlay it

Let’s walk through a realistic flow with Neovim (but the pattern applies to any package):

  1. Install a package (simple)
environment.systemPackages = with pkgs; [
  neovim
];

Rebuild and you can run nvim.

  1. Add user-level configuration (Home Manager)
home-manager.users.brandon = { pkgs, ... }: {
  home.stateVersion = "24.05";

  # Example: keep minimal dotfiles in Nix for portability
  programs.git.enable = true;
  programs.git.userName = "Your Name";
  programs.git.userEmail = "[email protected]";

  # Example Neovim plugin manager or any nvim lua/vim config
  xdg.configFile."nvim".source = pkgs.lib.cleanSource ./config/nvim; # rebuild for changes
};
  1. Prefer rapid iteration? Use an out-of-store symlink
xdg.configFile."nvim".source =
  config.lib.file.mkOutOfStoreSymlink "/Users/you/dev/dotfiles/config/nvim";

Edit in your working tree; no rebuild needed. This is perfect for fast iteration. When you’re happy, move that directory back under your repo and switch to cleanSource again for fully reproducible builds.

  1. Overlay Neovim to a specific version (avoid breakage)

Overlays let you replace packages in your pkgs set. The safest way to “pin to a version” is to source the package from a specific, pinned nixpkgs revision.

inputs = {
  nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
  nixpkgs-neovim-locked.url = "github:NixOS/nixpkgs/3cd1234..."; # your chosen commit
  # ...
};

# later
let
  system = "aarch64-darwin";
  neovimOverlay = final: prev: {
    neovim = inputs.nixpkgs-neovim-locked.legacyPackages.${system}.neovim;
  };
in {
  nixpkgs.overlays = [ neovimOverlay ];
}

Why this helps: you can advance your main nixpkgs channel for security updates while keeping Neovim on a known-good build. When you’re ready, update the pinned commit and test.

NoteTip: overlays vs. overrideAttrs

You can also use overrideAttrs to customize builds, but for “use this exact package from a known revision,” sourcing from a pinned nixpkgs input is clear and robust.

Homebrew: when it’s useful (and what it isn’t)

Homebrew remains excellent for GUI apps (casks) and a quick onramp. You can even let nix-darwin manage it:

homebrew.enable = true;
homebrew.casks = [
  "visual-studio-code"
  "raycast"
  "arc"
];

But keep this in mind:

  • Brew packages are not stored in the Nix store and are not version-pinned by Nix.
  • Brew controls updates and rollbacks on its own schedule.
  • That’s fine for apps; for critical dev toolchains, prefer Nix to get reproducibility.

Out-of-store symlinks: edit configs without rebuilding

Sometimes you want the Nix-managed symlink, but the contents to live in your working tree so edits land instantly. Home Manager supports this pattern:

xdg.configFile."nvim".source =
  config.lib.file.mkOutOfStoreSymlink "/Users/you/dev/dotfiles/config/nvim";

home.file.".zshrc".source =
  config.lib.file.mkOutOfStoreSymlink "/Users/you/dev/dotfiles/zsh/.zshrc";

Pros: fastest feedback loop for dotfiles. Cons: your build references mutable paths; for fully locked builds, switch back to a repository path and cleanSource.

direnv: per-folder shells with flakes

direnv automatically loads/unloads environments when you cd into a folder. With nix-direnv, it becomes a fantastic per-project shell.

Enable it in Home Manager:

programs.direnv.enable = true;
programs.direnv.nix-direnv.enable = true;

In a project using flakes, create .envrc:

echo "use flake" > .envrc
direnv allow

Now every time you enter the directory, direnv activates the dev shell defined in your flake.

Minimal flake dev shell example:

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

  outputs = { nixpkgs, ... }:
  let
    forAllSystems = f: nixpkgs.lib.genAttrs [ "aarch64-darwin" "x86_64-darwin" ] (system:
      f (import nixpkgs { inherit system; }) system);
  in {
    devShells = forAllSystems (pkgs: system: {
      default = pkgs.mkShell {
        packages = with pkgs; [
          nodejs_20
          python3
          ripgrep
        ];
      };
    });
  };
}

Enter the folder → the tools are on your PATH, reproducibly.

Putting it together: a pragmatic workflow

  • Use Nix + flakes to pin inputs and define your mac + user configs.
  • Reach for Homebrew for GUI apps and quick trial installs.
  • For fast dotfile edits, use out-of-store symlinks; for locked-down builds, use cleanSource.
  • When a package breaks, pin it via an overlay to a known-good nixpkgs revision.
  • Use direnv to make per-project environments effortless.

Common commands

# Update inputs (flake.lock)
nix flake update

# Apply system + user changes (nix-darwin + Home Manager)
darwin-rebuild switch --flake .#brandon-mac

# Home Manager only (if used standalone)
home-manager switch --flake .#brandon

# See which derivation provides a binary
nix search nixpkgs ripgrep

With this setup, you get the best of both worlds: a reproducible base that’s easy to roll forward or back, and escape hatches for speed and experimentation when you need them.