Nixifying My Dotfiles

dotfiles nix tooling

I wrote about bootstrapping my dotfiles a few days ago. Homebrew, GNU Stow, a bootstrap script. It works on my Mac. The problem is I also use Linux, and the Brewfile is worthless there. Every time I SSH into a Linux box I end up cloning the repo, running stow.sh for the configs, and then installing packages by hand for twenty minutes. Same tools, different package managers, nothing shared.

I’d been putting off trying Nix because it looked like a lot of ceremony for what is fundamentally brew install plus symlinks. But the cross-platform thing kept nagging at me, so I made a branch and started experimenting.

The Stack

nix-darwin handles system-level macOS configuration: defaults, Homebrew casks, system packages. home-manager handles user-level everything: packages, dotfile symlinks, shell setup. Both are configured through Nix’s functional language and the whole thing is pinned with a flake.

On macOS, darwin-rebuild switch applies the entire stack. On Linux, home-manager switch handles the user side. Same repo, same package list.

Restructuring

The old repo had one directory per tool in stow’s expected layout: zsh/.zshrc, nvim/.config/nvim/init.lua, git/.gitconfig. Stow would symlink each directory’s contents into $HOME.

I moved everything into a configs/ directory and let home-manager handle the symlinks with mkOutOfStoreSymlink:

home.file.".zshrc".source =
  config.lib.file.mkOutOfStoreSymlink "${dotfiles}/zsh/.zshrc";

mkOutOfStoreSymlink is important. Without it, Nix copies files into /nix/store/ and the symlink points there, read-only. With it, the symlink points back to the git working tree, so I can edit configs and see changes immediately. Same behavior as stow.

Packages

The 345-line Brewfile became a home-manager module. All CLI tools go in one list:

home.packages = with pkgs; [
  ripgrep sd fd fzf zoxide
  eza bat dust duf procs
  xh delta difftastic hexyl
  starship atuin vivid gum
  hyperfine just watchexec tealdeer tokei
  shellcheck neovim tmux
];

Plus another 80 or so for Go, Rust, Python toolchains, infrastructure tools, network utilities, media processing. Everything was in nixpkgs. Two packages had different names: git-delta is delta in nixpkgs, and dust is du-dust. Everything else matched.

GUI apps can’t come from Nix on macOS because .app bundles don’t work well in the Nix store. nix-darwin has a Homebrew integration module that manages casks declaratively:

homebrew = {
  enable = true;
  onActivation.cleanup = "zap";
  casks = [
    "ghostty" "wezterm" "google-chrome"
    "docker-desktop" "slack" "1password"
    "aerospace" "spotify" "vlc"
  ];
};

cleanup = "zap" removes any cask that isn’t in the list. So if I install something to try it and don’t add it here, it gets cleaned up on the next rebuild. The cask list becomes the source of truth instead of whatever accumulates on the machine over time.

macOS Defaults

The osx/.osx script was 165 lines of defaults write commands. nix-darwin replaces it with typed options:

system.defaults.dock = {
  tilesize = 57;
  autohide = true;
  autohide-delay = 1000.0;
  mru-spaces = false;
};

These get applied on every darwin-rebuild switch. If a macOS update resets something, the next rebuild puts it back. I don’t have to remember to re-run a script.

The Rust Binary

My custom starship-segments binary was previously compiled locally with the Homebrew-provided libgit2 and the resulting binary committed to the repo. With Nix, Crane builds it as a proper derivation:

starshipSegmentsFor = system:
  let
    craneLib = crane.mkLib nixpkgs.legacyPackages.${system};
  in
  craneLib.buildPackage {
    src = craneLib.cleanCargoSource ./starship-segments;
    strictDeps = true;
    buildInputs = pkgs.lib.optionals pkgs.stdenv.isDarwin [
      pkgs.apple-sdk_15 pkgs.libiconv
    ];
  };

This actually caught a real problem. The old binary linked against /opt/homebrew/opt/libgit2/lib/libgit2.1.9.dylib. When I removed Homebrew packages in favor of Nix, that library vanished and Ghostty wouldn’t even open because the tmux launch script called starship-segments during prompt setup. dyld error, immediate crash. The Nix-built version pins its own libgit2 in the store, so the dependency is always satisfied.

Crane also handles cross-platform automatically. The flake defines the build for aarch64-darwin and x86_64-linux, so the binary gets compiled for whatever system you’re on.

The Flake

The flake.nix defines two entry points:

darwinConfigurations."mims-mbp" = nix-darwin.lib.darwinSystem { ... };
homeConfigurations."mim@linux" = home-manager.lib.homeManagerConfiguration { ... };

Both import the same shared modules. The darwin config adds system defaults and Homebrew casks. The linux config just sets the home directory. A justfile wraps the platform detection:

# Apply everything — auto-detects macOS vs Linux
just switch

Bootstrap on a new machine is three commands:

curl -sSf -L https://install.determinate.systems/nix | sh -s -- install
git clone https://github.com/mmichie/dotfiles ~/src/dotfiles
cd ~/src/dotfiles && just switch

What Broke

Two things, both the same pattern: hardcoded Homebrew paths.

My Ghostty launch command runs ~/bin/tmux-attach-or-new, which had TMUX=/opt/homebrew/bin/tmux on line 3. tmux was now in /etc/profiles/per-user/mim/bin/tmux courtesy of Nix. Ghostty showed a “failed to launch” error and wouldn’t open at all. Fix: use command -v tmux instead.

The starship config had command = "~/.config/starship/starship-segments git", pointing to the old stow’d binary. The Nix-built binary was on PATH but at a different location. Fix: change it to just command = "starship-segments git".

Both were five-minute fixes, but the first one locked me out of my terminal until I figured it out.

Where It Stands

The experiment is on a nix branch. I’ve been running it on my Mac and everything works: prompt, tmux, all 120+ CLI tools, macOS defaults, casks. nix flake check passes.

The real test is the next time I set up a Linux box. If home-manager switch --flake .#mim@linux gives me my full shell environment in one command, I’ll merge the branch. That’s the whole reason I did this.

The Nix language took some getting used to. It’s functional, it’s lazy, and the error messages are occasionally unhelpful. I spent more time reading documentation than writing configuration. But the old setup was four scripts run in the right order on the right platform. The new setup is just switch on either.

Discussion