Managing Dotfiles: Stow, chezmoi and the Bare Git Repo

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

StowBare repochezmoi
Extra toolOne small packageNoneOne binary
Real files or linksSymlinksReal filesReal files
Per-host configSeparate packagesBranchesTemplates
SecretsKeep them outKeep them outBuilt in
LearningMinutesMinutesAn hour
Bootstrap on new machineClone and stowClone and checkoutOne 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.