3 min read

Managing dotfiles with Nix

How I use Nix and Home Manager to manage dotfiles across two Macs and a Linux desktop from a single reproducible configuration.

The promise of my dotfiles was "one command to set up a new machine". In practice, that command rarely worked cleanly. A Homebrew package had changed its name, a plugin needed a new dependency, some CLI flag had been removed in the latest version. Each breakage was small and fixable — a quick manual patch, maybe a commit if I remembered — but they added up to a setup script I had no confidence in.

So I stopped running it. I'd set up a machine once, nurse it through the breakages, and then leave it alone. Re-running the scripts to pull in updates from another machine felt like a gamble: it might work, or it might silently break something I'd come to rely on. It was easier to just not bother. Which meant the dotfiles repo gradually stopped reflecting how any of my machines actually worked.

I switched to Nix. The promise was reproducibility and, at least so far, it delivered.

💻
My dotfiles are publicly available, you're welcome to use them for inspiration.

One flake, three machines

My setup covers two Macs — one personal, one for work — and a Linux desktop. Each one needs slightly different packages, different git identities, different paths to the same SSH signing tool. Before Nix, this meant a shared base config with platform-specific bootstrap scripts layered on top. Keeping three of those in sync was a project in itself.

With Nix everything lives in a single flake.nix. The three machines are defined as separate outputs:

darwinConfigurations."elliot@macos-personal" = ...
darwinConfigurations."elliot@macos-work" = ...
homeConfigurations."elliot@desktop" = ...

On macOS I use nix-darwin to manage the system layer — Finder settings, key repeat speed, system packages — and Home Manager for user-level config. On Linux, where there's no nix-darwin equivalent, it's Home Manager alone. Either way, running darwin-rebuild switch --flake . or home-manager switch --flake . rebuilds my environment from that single file.

☀️
I use Lix rather than vanilla Nix. It's a community fork with an actual installer, good defaults, and more sensible error messages. Flakes are enabled out of the box, which is a relief because getting flakes working in vanillar Nix involves some configuration friction.

Programs as modules

Individual programs each get their own file in home-manager/programs/. Git lives in git.nix, the shell in zsh.nix, the editor in zed.nix. Each profile imports the modules it needs:

# macos-work.nix
imports = [
  ./packages.nix
  ./programs/git.nix
  ./programs/zsh.nix
  ./programs/developer.nix
  ./programs/nodejs.nix
  ./programs/beam.nix
  ./programs/python-dev.nix
  ./programs/zed.nix
];

All my machines include Elixir, Erlang, Node, zsh (and others) because I need them for both work and side projects. My work machine also includes some other stuff that I only need there. This used to be a cli flag and comment in a README that I'd forget to read. Now it's the configuration itself.

Injecting per-environment config

The part I'm most pleased with is how git identity gets handled across environments. The work Mac commits with a different email and signing key than the personal one. Rather than templating the git config, I pass this in through Home Manager's extraSpecialArgs:

extraSpecialArgs = {
  gitConfig = {
    userName = "Elliot Blackburn";
    userEmail = "[email protected]";
    gpgSshProgram = "/Applications/1Password.app/Contents/MacOS/op-ssh-sign";
    signingKey = "ssh-ed25519 AAAA...";
  };
};

git.nix receives this as a function argument and uses it directly:

{ config, pkgs, gitConfig, ... }:
{
  programs.git.settings.user.name = gitConfig.userName;
  programs.git.settings.user.email = gitConfig.userEmail;
}

There's nothing encrypted here — the values are sitting in the flake in plain text. That's fine for my purposes: the repository is private, and anything truly sensitive lives in 1Password anyway. The SSH signing key path points to 1Password's signing agent, so the key itself never touches the config.

Where Nix stops and shell starts

Nix is declarative. It's excellent at saying "this machine should have these packages with this configuration." It's less convenient for things you want to tweak rapidly, test, and iterate on without a full rebuild.

My shell functions live in shell/functions/ and get sourced at login. A git.zsh function, a prompt builder, a JWT decoder — things I'm more likely to tinker with on a Tuesday afternoon without wanting to touch the flake. Nix manages that the files are in the right place; the shell handles the rest.

There's also a ~/.localrc escape hatch for machine-specific configuration that isn't worth adding to the repo: PATH additions for tools installed outside Nix, environment variables for a particular project, that kind of thing. It's loaded last, so it can override anything.

What this actually gives you

Setting up the new Linux machine took about ten minutes. Install Lix, clone the repo, run home-manager switch. The environment that came out the other side was identical to the one I'd been using for months on the Mac, minus the platform-specific stuff that shouldn't have been there anyway.

The command to set up a new machine works now. I've even started running it to pull in updates.