Migrating from GNU stow to chezmoi | Redowan's ReflectionsSkip to content
I’ve been managing my dotfiles with GNU stow<br>for a few years. I even wrote a piece with a<br>corny title<br>about that setup back in 2023. Stow served me well, but managing symlinks<br>across multiple devices slowly became a pain in the butt.<br>So I started looking around for a better tool and even considered writing my own. Then a<br>colleague pointed me to chezmoi<br>, and so far I’m liking it a lot. It does everything I<br>need, and I’ve started tracking my agent skill files with it too.<br>The machines #<br>I run three Macs: a MacBook Pro for work, a MacBook Air for personal use, and a Mac Mini<br>that acts as a small personal server. The Mini mostly gets SSHed into from the other two.<br>It’s still a Mac with my shell on it, so the same dotfiles apply.<br>I also keep a few Linux VMs around, but I rarely need my dotfiles on servers. Ansible<br>provisions those. This workflow is strictly for the desktop machines.<br>When I outgrew stow #<br>Stow’s model is symlinking. The config files live in a git repo, grouped into directories<br>that stow calls packages, and stowing a package links its files into the home directory. For<br>a single machine it still holds up. The commands are idempotent and there’s almost nothing<br>to learn.<br>The trouble is that symlinks cut both ways. Every edit on every machine writes straight<br>through the link into that machine’s clone of the repo. Months later I’d find dirty working<br>trees on the Air with changes I had no memory of making. Half of them conflicted with<br>whatever the Pro had already pushed. Keeping three clones converged turned into a chore.<br>Fresh machines were the other half of the problem. Stow won’t link over a real file. By the<br>time Homebrew and a couple of tools have run on a new Mac, files like ~/.zprofile and<br>~/.gitconfig already exist. Bootstrapping meant cloning the repo, deleting the conflicting<br>files by hand, and restowing every package while trying to remember what I’d named them. And<br>stow only does files. Homebrew packages and macOS settings lived in separate scripts that I<br>had to remember to run in the right order.<br>How chezmoi works #<br>Chezmoi keeps a source directory at ~/.local/share/chezmoi, which is a regular git repo.<br>chezmoi add ~/.zshrc copies the live file into it and names the copy dot_zshrc. Adding<br>~/.config/gh/config.yml creates dot_config/gh/config.yml, parent directories included. I<br>never create those names by hand since chezmoi add derives them from the real paths. The<br>tree ends up mirroring the home directory, with every leading dot spelled out as a dot_<br>prefix.<br>dot_ is one of several attributes<br>that chezmoi encodes into file names. A private_<br>prefix strips group and world permissions from the file. A .tmpl suffix turns the file<br>into a Go template that can read per-machine data. I use templates sparingly, and every one<br>of them shows up later in this post.<br>chezmoi apply goes the other way. It writes every tracked file back to the home path its<br>name spells out, so dot_zshrc lands at ~/.zshrc. The copies are real files, not<br>symlinks. The source directory is the single source of truth. When a file in the home<br>directory stops matching its source copy, chezmoi diff shows the difference and the next<br>apply puts it back.<br>Losing the automatic write-through of symlinks turned out to be the thing I like most.<br>Nothing changes in the repo unless I deliberately put the change there.<br>What I track #<br>All of it sits in that source directory. chezmoi cd drops me into a subshell there, and<br>here’s the entire tree:<br>~/.local/share/chezmoi<br>├── .chezmoi.toml.tmpl<br>├── .chezmoiignore<br>├── .chezmoiscripts<br>│ └── macos<br>│ ├── run_onchange_after_disable-macos-animations.sh<br>│ ├── run_onchange_after_init-macos-machine.sh.tmpl<br>│ └── run_onchange_before_install-homebrew-bundle.sh.tmpl<br>├── .gitignore<br>├── Brewfile<br>├── README.md<br>├── dot_agents<br>│ └── skills<br>│ ├── go-modernize<br>│ ├── go-styleguide<br>│ └── meatspeak<br>├── dot_claude<br>│ ├── settings.json<br>│ └── symlink_skills.tmpl<br>├── dot_codex<br>│ └── private_config.toml<br>├── dot_config<br>│ ├── gh<br>│ │ ├── config.yml<br>│ │ └── private_hosts.yml<br>│ └── ghostty<br>│ └── config<br>├── dot_gitconfig<br>├── dot_gitconfig-pers<br>├── dot_gitconfig-werk<br>├── dot_shellcheckrc<br>├── dot_zsh_aliases<br>└── dot_zshrc
The list is short because I dislike customizing tools and stick to defaults where I can. The<br>dotfiles proper are the zsh, git, shellcheck, ghostty<br>, and GitHub CLI configs. I track<br>Claude Code’s settings.json and Codex’s config.toml too, so the agents behave the same<br>on every machine. The private_ prefix on gh’s hosts.yml and the Codex config keeps those<br>two at 0600. I’ll talk about the skills under dot_agents at the end.<br>The three gitconfigs split my identities. All my projects live under two directories,<br>~/canvas/werk/ for work and ~/canvas/pers/ for everything personal, and both exist on<br>every machine. The main gitconfig routes identity by where a repo...