Managing Dotfiles Declaratively

Updated

September 12, 2026

Managing Dotfiles Declaratively

Git-bare + stow leaves stale symlinks and a README of “also copy this file.” The boring default is: xdg.configFile and home.file so every live config is a symlink into /nix/store, generated from this Home Manager generation.

Mental model

Option Live path
xdg.configFile."desk/settings.json" ~/.config/desk/settings.json
home.file.".desk.rc" ~/.desk.rc
xdg.dataFile."desk/banner.txt" ~/.local/share/desk/banner.txt

Each value can be .text (inline string), .source (a file in git), or .template (less common). Home Manager creates the parent directories and a generation-specific symlink.

The file in $HOME is read-only when it points at the store. That is the feature: if you need to change it, you change git and switch. An editor that “helpfully” writes in place will get Permission denied or silently copy-break the symlink depending on the editor.

xdg.configFile."desk/settings.json".force = true; replaces a leftover regular file. Prefer home.backupFileExtension (previous chapter) so the first switch copies *.hmbak instead of aborting. onChange runs a command after the file is replaced (systemctl --user restart desk-sync.service) — keep it short; it is not a substitute for a real user unit.

git: desk/settings.json
        │  home.file / xdg.configFile
        ▼
/nix/store/…-home-manager-files/.config/desk/settings.json
        ▲
        └── ~/.config/desk/settings.json  (symlink)

Worked examples

Case 1: Inline JSON from Nix

Save as dotfiles.nix:

# dotfiles.nix
{
  xdg.configFile."desk/settings.json".text = builtins.toJSON {
    theme = "dark";
    autoSync = true;
    window = "A";
  };
}

Import this from home.nix. After home-manager switch:

readlink -f ~/.config/desk/settings.json
cat ~/.config/desk/settings.json

Output:

/nix/store/…-settings.json
{"autoSync":true,"theme":"dark","window":"A"}

builtins.toJSON keeps commas honest. Do not hand-write JSON in a Nix multiline string if you can avoid it.

Case 2: Source a file that lives in git

Save as gitconfig-extra.txt next to the module:

# gitconfig-extra.txt
[alias]
    st = status
    co = checkout

Save as dotfiles-source.nix:

# dotfiles-source.nix
{
  xdg.configFile."git/ignore".text = ''
    result
    .direnv/
    *~
  '';

  home.file.".desk/aliases".source = ./gitconfig-extra.txt;
}
home-manager switch
cat ~/.desk/aliases

The source file is copied into the store at build time. Editing ~/.desk/aliases does not change git. Editing ./gitconfig-extra.txt and switching does.

Case 3: On-change hook

Save as onchange.nix:

# onchange.nix
{
  xdg.configFile."desk/reload-flag".text = "reload-v1";
  home.activation.deskPing = ''
    echo "desk configs refreshed at $(date -u +%H:%M:%S)" >> "$HOME/.desk/activation.log"
  '';
}

Prefer dedicated modules (programs.git, programs.ssh) over home.activation scripts. Activation runs as your user on every switch; keep it idempotent.

Case 4: Mutable file when a program insists on writing

Some tools rewrite their config on every start (a desktop app dumping window geometry). Forcing a store symlink makes the app crash or clone the file.

# mutable.nix
{
  xdg.configFile."desk/geometry.json" = {
    text = builtins.toJSON { x = 0; y = 0; };
    force = true;
  };
}

force = true replaces a user-created file with the symlink. It does not make the store writable. If the app must write, do not manage that file. Manage a config.example and leave the live file alone.

Case 5: Inspect what this generation linked

home-manager files | head
ls -l ~/.config/desk

Every managed path should be a symlink into /nix/store. A regular file at a path you thought you managed means the app (or you) replaced the symlink.

The trap

The trap is chmod u+w ~/.config/desk/settings.json and editing in place. The next home-manager switch either overwrites you or refuses to clobber. Your change was never in git.

The other trap is stowing and Home Manager on the same path. Two tools fight. Pick Home Manager; delete the stow tree.

The boring rule

  • Config that should be identical on every desk machine lives in git and is linked via xdg.configFile / home.file.
  • Prefer .text + toJSON / real modules over huge copied blobs.
  • Store symlinks are read-only. Edit the source, then switch.
  • If an app writes the file, do not manage that path.
  • Do not mix stow, chezmoi, and Home Manager on one directory.

Try this

  1. Add xdg.configFile."desk/motd".text = "window A";, switch, cat ~/.config/desk/motd.
  2. ls -l ~/.config/desk/motd — confirm a symlink. Try echo x >> ~/.config/desk/motd and record the error.
  3. Change the motd text, switch, confirm the store path hash on readlink -f moved.
  4. Create a real file at ~/.config/desk/motd after rm of the symlink, switch without force, and read Home Manager’s conflict message. Then restore management with force = true once, and stop using force as a habit.