Managing Dotfiles Declaratively
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.jsonOutput:
/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/aliasesThe 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/deskEvery 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
- Add
xdg.configFile."desk/motd".text = "window A";, switch,cat ~/.config/desk/motd. ls -l ~/.config/desk/motd— confirm a symlink. Tryecho x >> ~/.config/desk/motdand record the error.- Change the motd text, switch, confirm the store path hash on
readlink -fmoved. - Create a real file at
~/.config/desk/motdafterrmof the symlink, switch withoutforce, and read Home Manager’s conflict message. Then restore management withforce = trueonce, and stop using force as a habit.