Nix on macOS: flakes, Home Manager, nix-darwin, overlays
A practical guide to reproducible dev setups on 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
- 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.confMinimal flake with nix-darwin + Home Manager
Here’s a compact but useful flake.nix that:
- Pins
nixpkgsandhome-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-macThis 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):
- Install a package (simple)
environment.systemPackages = with pkgs; [
neovim
];Rebuild and you can run nvim.
- 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
};- 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.
- 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.
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 allowNow 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 ripgrepWith 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.