Nix and Home Manager for MacOS
Created on: 2026.03.06
My old MacOS setup was functional, but had a bit of Mos Eisley energy: noisy and full of irritating manual steps. Apps were installed in different ways, shell tweaks lived in random files, and after each reinstall I had to rebuild everything from memory.
The goal was to make my system setup reproducible, modular, and easy to evolve without turning configuration into one giant bash script. That is why this setup is built on nix-darwin + home-manager, with profile-based composition and a practical split between Nix and Homebrew. An additional benefit is that, I might create more profiles in the future, for other machines (eg. my work laptop).
Feel free to explore the code in the nix-macos repository.
What was the quest?
Three requirements guided the design:
- One-command rebuild of a full machine,
- Clean feature toggles (
base,dev,desktop,gaming) without editing many places, - Realistic MacOS packaging, with
nix,brewand Apple Store apps.
Repository map
Here is the simplified structure:
nix-macos/
├── flake.nix
├── home.nix
├── hosts/
│ └── terra/
└── modules/
├── apps/
├── darwin/
└── home/
├── profiles/
├── programs/
└── theme/
The tree is easier to read if you think of it as layers, not folders.
flake.nix starts everything and passes shared context, hosts/ only identifies the machine, modules/darwin and modules/apps define system behavior and packaging, and home.nix with modules/home shapes the user environment.
This way, host identity stays small while behavior stays reusable.
Why flake.nix is intentionally boring
flake.nix is the composition entrypoint of this setup: it makes decisions about structure, not implementation details. The mkDarwin helper takes host parameters (hostName, system, username, userHome, profiles) and builds nix-darwin.lib.darwinSystem from a stable module list.
darwinConfigurations = {
terra = mkDarwin {
hostName = "terra";
system = "aarch64-darwin";
username = "lukzmu";
userHome = "/Users/lukzmu";
profiles = ["base" "dev" "desktop" "gaming"];
};
};
The important part is passing profiles via specialArgs. That one choice lets every downstream module stay simple: it only checks whether a profile is enabled and imports the matching bits. No hidden per-host branching spread around the codebase, no “who changed this and where?” debugging saga.
Thin hosts, heavy reuse
{pkgs, ...}: {
networking.hostName = "terra";
system.primaryUser = "lukzmu";
}
hosts/terra/default.nix only sets hostname and primary user. That is on purpose. Host modules should answer “who is this machine?”, while shared modules answer “how do machines behave?”.
This is one of the highest-leverage patterns in the repo. Adding a new host means declaring identity and selecting profiles, nothing less and nothing more.
Why apps are split between Nix and Homebrew?
Inside modules/apps/default.nix, each profile imports two files: one from modules/apps/nix/ and one from modules/apps/brew/. If dev is enabled, both nix/dev.nix and brew/dev.nix are loaded. Same for base, desktop, and gaming.
That split is not ideological, it is operational. Nix handles CLI and reproducibility very well (git, ripgrep, terraform, neovim, etc.). Homebrew casks and masApps cover several MacOS-native desktop flows more smoothly (brave-browser, wezterm, signal, App Store apps, game launchers). Trying to force a single universal package lane on MacOS usually creates more pain than order.
Additionally, some apps that even might be on Nix package manager… don't have support for Darwin. When something was available on Nix, I tried using that for simplicity. If it wasn't - Apple Store + Homebrew took place. A good example might be below for handling gaming apps. Nix could only support Discord:
{pkgs, ...}: {
environment.systemPackages = with pkgs; [
discord
];
}
… but Homebrew handled my other gaming apps (worth looking that Steam is here as well, as it is available on Nix, but not for Darwin):
{...}: {
homebrew = {
enable = true;
casks = [
"battle-net"
"curseforge"
"steam"
"warcraft-logs-uploader"
];
};
}
Darwin modules own system policy
modules/darwin/core.nix handles the Nix trust/runtime side (flakes, substituters, trusted keys, unfree policy). modules/darwin/system.nix owns MacOS behavior: Touch ID for sudo, Finder and Dock defaults, keyboard preferences, screenshot location, login window settings, and system.stateVersion. modules/darwin/browser.nix sets Brave as default via an activation script. Keeping this layer separate from Home Manager avoids coupling OS policy to user app preferences. I can swap editor tooling later without accidentally touching login-window behavior or Dock layout.
I feel like this part can get some makeover in the future - it depends too much on specific applications (eg. for dock building).
Home Manager profiles and program modules
home.nix mirrors the same profile model. It always imports shared theme/program defaults, then conditionally imports profile modules. Current profile behavior is:
| Profile | Home Manager modules | Practical role |
|---|---|---|
base |
shell + git | Baseline interactive workflow and defaults |
dev |
neovim + wezterm | Development-focused user tooling |
desktop |
none yet (package-driven today) | Extension point for desktop-specific user config |
gaming |
none yet (package-driven today) | Extension point for gaming-specific user config |
Program modules are kept per domain (zsh, git, neovim, wezterm) to minimize blast radius. Changing Git defaults does not require editing shell config, and vice versa. This keeps review and rollback straightforward.
It is worth mentioning, that when an app doesn't need specific settings and just works "as is" - then you don't need a configuration for it in programs.
Additionally a great thing about Nix is that I can still keep my .lua configurations for apps. An example for neovim:
{...}: {
programs.neovim = {
enable = true;
defaultEditor = true;
viAlias = true;
vimAlias = true;
};
xdg.configFile."nvim".source = ./config;
}
While source is the actual thing I would normally put into ~/.config:
neovim
├── config
│ ├── init.lua
│ ├── lazy-lock.json
│ └── lua
│ ├── config
│ │ ├── init.lua
│ │ ├── keymap.lua
│ │ └── window.lua
│ └── plugins
│ ├── cmp.lua
│ ├── conform.lua
│ └── ...
└── default.nix
Theme activation without drama
modules/home/theme/wallpaper.nix is a good example of pragmatic declarative setup. It deploys wallpaper.png into ~/Pictures/Wallpapers/, then applies it during Home Manager activation using m-cli, with AppleScript fallback across desktops. The script checks for a running Dock and suppresses hard failures, so activation remains idempotent and resilient.
As a big Lovecraft fan, I'd love to attribute Guillem H. Pongiluppi for creating this awesome Shadow over Innsmouth art, that I used as my wallpaper.
For now theming only goes around setting the wallpaper, but I might thing of something more in the future.
Final outcome
The result is a MacOS setup that behaves less like a one-off pet machine and more like versioned infrastructure. Profiles act as contracts, modules isolate concerns, and the host layer stays clean.
Rebuild remains a single command:
sudo nix run nix-darwin/master#darwin-rebuild -- switch --flake .#terra
Or even simpler, when you start using mise configurations:
mise switch terra
Once configuration is code, “works on my machine” becomes less of a recurring problem and more of an implementation detail.