Managing Dotfiles: Stow, chezmoi and the Bare Git Repo
Every developer eventually has the same realisation on a new machine: the shell, editor and terminal setup accumulated over years is on the old one, and reproducing it from memory takes a week. Version control solves this, and there are three reasonable ways to do it.
What is worth keeping
# the usual suspects
~/.bashrc ~/.zshrc ~/.profile
~/.gitconfig
~/.tmux.conf
~/.config/nvim/
~/.config/kitty/ ~/.config/alacritty/
~/.ssh/config # config, never keys
And what must never go in:
~/.ssh/id_* # private keys
~/.gnupg/ # private keyring
~/.aws/credentials
~/.netrc
~/.config/**/token*
~/.bash_history # frequently contains secrets
Our gpg guide and ssh keys guide cover why those are different in kind from configuration.
Option one: GNU Stow
Stow is a symlink manager. The mental model is one directory per application, laid out as it appears relative to your home directory.
sudo apt install stow
~/dotfiles/
├── bash/
│ ├── .bashrc
│ └── .profile
├── git/
│ └── .gitconfig
├── nvim/
│ └── .config/nvim/init.lua
└── tmux/
└── .tmux.conf
cd ~/dotfiles
stow bash git nvim tmux
# check
ls -l ~/.bashrc
# ~/.bashrc -> dotfiles/bash/.bashrc
Stow reads the tree inside each package and creates matching symlinks in the parent of the current directory, which is why the repository lives directly in $HOME.
# remove one
stow -D nvim
# after moving files around
stow -R nvim
# see what would happen, change nothing
stow -nv nvim
stow -nv before any real run. It shows the symlinks it intends to create and the conflicts it has found.
The conflict you will hit: stow refuses to overwrite an existing regular file. On a fresh machine with a distribution-provided ~/.bashrc, you move it aside first.
mv ~/.bashrc ~/.bashrc.orig
stow bash
The gotcha worth knowing: if ~/.config/nvim does not exist, stow symlinks the whole nvim directory. If it does exist, stow descends and links individual files. That means the result depends on what was already there, which is confusing when two machines end up different. --no-folding forces per-file links and makes it predictable.
Per-machine differences are handled by choosing packages:
stow bash git tmux # everywhere
stow nvim # dev machines
stow sway waybar # the laptop only
Stow is the right default for most people. One small package, no state, and ls -l tells you exactly what is going on.
Option two: the bare git repository
No extra tools, no symlinks. Your real files are tracked where they live.
git init --bare "$HOME/.dotfiles"
alias dot='git --git-dir=$HOME/.dotfiles --work-tree=$HOME'
dot config --local status.showUntrackedFiles no
echo "alias dot='git --git-dir=\$HOME/.dotfiles --work-tree=\$HOME'" >> ~/.bashrc
dot add .bashrc .gitconfig .tmux.conf
dot commit -m "initial dotfiles"
dot remote add origin git@github.com:you/dotfiles.git
dot push -u origin main
status.showUntrackedFiles no is not optional. Without it, dot status lists every file in your home directory, which is unusable.
On a new machine:
git clone --bare git@github.com:you/dotfiles.git "$HOME/.dotfiles"
alias dot='git --git-dir=$HOME/.dotfiles --work-tree=$HOME'
dot checkout
dot config --local status.showUntrackedFiles no
dot checkout fails if any tracked file already exists, listing which. Move those aside and retry.
Why people like it: the files are real files in their real places. Nothing to learn beyond git, which you already know. Editing a config is editing the config.
Why people leave it: per-machine differences are branches or conditional includes, and branches for host differences become tedious quickly. There is also a real hazard in dot add -A, which would happily commit your entire home directory including keys.
Option three: chezmoi
chezmoi keeps a source state and applies it to your home directory through a template engine. The source and the destination are separate files, which is what makes per-machine variation work properly.
sh -c "$(curl -fsLS get.chezmoi.io)"
# or from your package manager
chezmoi init
chezmoi add ~/.bashrc
chezmoi add ~/.gitconfig
chezmoi cd # the source directory
git add -A && git commit -m "dotfiles"
chezmoi cd # exit with ctrl-d
chezmoi edit ~/.bashrc # edit the source
chezmoi diff # what would change
chezmoi apply # write it out
chezmoi update # pull and apply in one step
chezmoi diff before chezmoi apply is the habit. Applying is a write to your real config.
Templates, the actual reason to use it
{{- if eq .chezmoi.os "darwin" }}
export PATH="/opt/homebrew/bin:$PATH"
{{- end }}
{{- if eq .chezmoi.hostname "work-laptop" }}
export HTTP_PROXY=http://proxy.corp:8080
git config --global user.email "{{ .workEmail }}"
{{- end }}
export EDITOR={{ .editor | default "vim" }}
# ~/.config/chezmoi/chezmoi.toml
[data]
editor = "nvim"
workEmail = "you@company.com"
One repository, one file per config, branching on hostname and operating system. With Stow you would need separate packages or a per-host include; here it is a conditional.
Secrets
# pulls from your password manager at apply time
export API_TOKEN={{ (onepasswordDetailsFields "token").credential.value }}
export DB_PASS={{ (bitwarden "item" "database").login.password }}
# or age encrypted files in the repository
chezmoi add --encrypt ~/.config/app/credentials
The secret is never in the repository in plaintext. chezmoi fetches or decrypts it when applying. This is the feature that justifies the extra complexity for many people, and our secrets management guide covers the age side.
The cost is a tool with real concepts to learn, a source directory whose filenames are mangled with prefixes like dot_ and private_, and the indirection that the file you edit is not the file that runs.
Choosing
| Stow | Bare repo | chezmoi | |
|---|---|---|---|
| Extra tool | One small package | None | One binary |
| Real files or links | Symlinks | Real files | Real files |
| Per-host config | Separate packages | Branches | Templates |
| Secrets | Keep them out | Keep them out | Built in |
| Learning | Minutes | Minutes | An hour |
| Bootstrap on new machine | Clone and stow | Clone and checkout | One command |
Start with Stow. If you find yourself fighting per-machine differences or wanting secrets in the repository, move to chezmoi. The bare repo is for people who want zero dependencies and are content to handle variation by hand.
Migrating between them is not hard, because the hard part is having the files under version control at all.
Per-host differences without a template engine
Whichever method you pick, this pattern covers most of the need:
# at the end of ~/.bashrc
[ -f ~/.bashrc.local ] && . ~/.bashrc.local
# ~/.gitconfig
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-work
~/.bashrc.local stays untracked and holds machine specifics. Git’s includeIf switches identity by directory, which is how you avoid committing to a work repository as your personal address. Our shell startup files guide covers where these belong.
A bootstrap script
The thing people forget until they need it.
#!/usr/bin/env bash
# ~/dotfiles/bootstrap.sh
set -euo pipefail
if command -v apt >/dev/null; then
sudo apt update
sudo apt install -y git stow tmux neovim ripgrep fd-find fzf
elif command -v dnf >/dev/null; then
sudo dnf install -y git stow tmux neovim ripgrep fd-find fzf
elif command -v pacman >/dev/null; then
sudo pacman -S --needed --noconfirm git stow tmux neovim ripgrep fd fzf
fi
cd "$(dirname "$0")"
for pkg in bash git tmux nvim; do
[ -d "$pkg" ] || continue
stow -v --no-folding "$pkg"
done
echo "done. restart your shell."
Package names differ across distributions, which is the tedious part and the reason to write it down once. Our bash error handling guide covers making it fail loudly rather than halfway.
Test it in a container
The step that catches everything.
podman run --rm -it -v ~/dotfiles:/root/dotfiles:Z debian:stable bash
# inside:
apt update && apt install -y git stow
cd /root/dotfiles && ./bootstrap.sh
A fresh container has none of your assumptions: no tools installed, no existing config, no environment variables you forgot you set five years ago. A bootstrap that works on your own machine tells you nothing, because your machine is already configured. Our container basics guide covers the mechanics.
Mistakes worth avoiding
Committing a secret. Git history means deleting it later is insufficient; it remains in the commits. If the repository is public, treat anything ever committed as disclosed and rotate it. Check before the first push:
git log -p | grep -iE 'BEGIN.*PRIVATE KEY|password|secret|token|api[_-]?key'
Tracking generated files. Plugin directories, caches, compiled artefacts, .zcompdump. Use a .gitignore and be strict about it.
One enormous .bashrc. Split it into sourced files. Easier to read, easier to keep one machine’s specifics out.
No documentation. A short README listing what is here and how to bootstrap it. You will read it in two years and be grateful.
Symlinking ~/.ssh wholesale. ssh refuses keys with loose permissions, and symlinked directories make that harder to reason about. Track ~/.ssh/config only, per our ssh hardening guide.
Frequently Asked Questions
What is the simplest way to version control my dotfiles?
GNU Stow with a git repository. Keep each application in its own subdirectory mirroring the path from your home directory, and stow creates the symlinks. It is one small package, the layout is obvious, and there is no state to understand beyond symlinks.
What is the difference between Stow and chezmoi?
Stow symlinks files into place, so the file in your repository and the file in your home directory are the same file. chezmoi copies from a source state through a template engine, so the two are separate and can differ per machine. Stow is simpler, chezmoi handles per-host differences and secrets.
How do I handle machine specific differences in dotfiles?
With Stow, split into packages and only stow what applies, or source a per-host file at the end of your config. With chezmoi, use templates that branch on hostname or operating system. The per-host include is the simplest approach and works with any method.
Should I put secrets in my dotfiles repository?
No, and git history means removing them later is not enough because they stay in the commits. Keep secrets out entirely, reference them from a password manager or an age encrypted file, and if a repository is public treat anything that has ever been committed as disclosed and rotate it.
What is the bare git repo dotfiles trick?
You create a bare repository and set your home directory as its work tree, then use an alias that passes those paths to git. Your real files are tracked in place with no symlinks and no extra tools. The cost is needing to configure the repository to ignore untracked files, since otherwise git status lists your entire home directory.
How do I test a dotfiles setup without breaking my machine?
Run it in a container. Mounting the repository into a fresh Debian or Fedora image and bootstrapping from scratch catches missing dependencies and bad assumptions in a way that testing on your own configured machine never will.