TL;DR Six ways of syncing dotfiles later, the one I kept is chezmoi for the files and mise for the tool versions, with the mise config living inside the chezmoi repo. Templates handle the work laptop against the personal desktop, age handles the secrets, and a fresh machine is one command and about five minutes. The walk-through goes from one managed file to the bootstrap I run for real.
My .zshrc is older than some of my coworkers’ careers. Same for my .vimrc and my tmux config. They’ve grown into a setup that does exactly what I want, and I’d be lost without them. The catch is that I run more than one machine. A laptop, a desktop, now and then a throwaway VM for testing. For years, keeping those in sync was a pile of hacks I didn’t trust.
What I want out of dotfiles is portability. Any machine I sit down at should feel like mine within minutes, and I want to know exactly what got written where. Finding a setup that delivered both took years of dead ends.
Everything I tried first
It started with the classic move, a bare git repo in my home directory. git init --bare ~/.dotfiles, a couple of aliases, done. It works, until the day you run git clean in the wrong place and wipe your own configs. Machine-specific settings? You’re on your own.
Then I found GNU Stow. The idea is nice. You organise your dotfiles into directories and Stow symlinks them into place. I ran it for a long stretch. But Stow has no idea what a template is, and my work laptop and my personal desktop need different .gitconfig settings. So you end up with gitconfig-work and gitconfig-personal and a manual switch, and sooner or later you forget to flip it.
At some point I thought, I already run Ansible for my servers, why not for my dotfiles? So I built a playbook for my workstation. Roles, templates, handlers, the full circus. It was beautiful. It was also wildly over-engineered. Running ansible-playbook every time I touched my .zshrc felt absurd, and writing YAML to copy a single file is its own kind of pain.
I tried a few more along the way. yadm is git-based and a step up from the bare repo, but the templating felt bolted on. dotbot has a clean YAML config and wasn’t flexible enough for what I wanted. home-manager on Nix is very powerful, and I wasn’t ready to go all-in on Nix for it.
None of them stuck. Each one solved the problem I’d come with and had a gap somewhere else.
Then I landed on chezmoi and paired it with mise. That’s the setup I’ve kept. Below is how it’s built, starting with one managed file and ending with the bootstrap I run on a fresh machine.
What a plain repo can’t do
On paper, dotfile management is trivial. They’re just config files. The trouble starts the moment you have a second machine. My laptop wants different settings from my desktop. My .gitconfig carries tokens and my .ssh/config holds hostnames I don’t want sitting in a public repo. I need Go 1.21 on one box and 1.22 on another. And on a fresh machine I’d like to be productive in minutes rather than spend an afternoon copying files around.
A plain git repo handles none of that. You need templating, secret handling and a repeatable bootstrap, and that’s the gap chezmoi fills.
chezmoi, starting with one file
Chezmoi is a dotfile manager that covers all of the above. The name is French for “at my place”, which fits, since it carries your home environment wherever you go.
Source state and home directory
Chezmoi keeps a “source state” in ~/.local/share/chezmoi/ and applies it to your home directory. The source state can hold:
- Regular files
- Templates, with per-machine variables
- Scripts, for things you can’t capture as a static file
- Encrypted files, for secrets
You can read exactly what gets written where, and that’s the property I care about most. No black box deciding things on my behalf.
Install and add one file
# Install
brew install chezmoi # macOS
sudo pacman -S chezmoi # Arch
sudo apt install chezmoi # Debian/Ubuntu
# Initialize
chezmoi init
# Add your first file
chezmoi add ~/.zshrc
That’s the whole minimum. Your .zshrc is now managed by chezmoi.
The daily loop
# Add a file to chezmoi
chezmoi add ~/.config/nvim/init.lua
# Edit a file (opens in $EDITOR)
chezmoi edit ~/.zshrc
# See what would change
chezmoi diff
# Apply changes to home directory
chezmoi apply
# Push to git
chezmoi cd
git add -A && git commit -m "Update zshrc" && git push
Templates for per-machine config
This is what Stow couldn’t do. Go templates let you render different config per machine from a single source file.
First, define your data in ~/.config/chezmoi/chezmoi.toml:
[data]
hostname = "laptop"
work = true
email = "tom@example.com"
Then use it in a template. Adding a file with --template gives it a .tmpl suffix in the source state:
chezmoi add --template ~/.gitconfig
Now ~/.local/share/chezmoi/dot_gitconfig.tmpl:
[user]
name = Tom Meurs
email = {{ .email }}
{{ if .work -}}
[url "git@gitlab.work.com:"]
insteadOf = https://gitlab.work.com/
{{- end }}
On my work laptop the GitLab URL rewrite gets rendered in. On my personal machines it stays out. One file, two outcomes, and no switch to forget.
Machine detection
You can also let chezmoi detect machine properties:
# ~/.config/chezmoi/chezmoi.toml
[data]
hostname = {{ .chezmoi.hostname | quote }}
os = {{ .chezmoi.os | quote }}
{{ if eq .chezmoi.hostname "work-laptop" -}}
work = true
{{- else -}}
work = false
{{- end }}
Or ask on first setup:
# chezmoi.toml.tmpl
[data]
email = {{ promptString "email" | quote }}
work = {{ promptBool "work machine" }}
Secrets, encrypted with age
Never commit secrets in plain text. Chezmoi integrates with age, so secrets can live in the same repo without leaking.
# Generate age key
age-keygen -o ~/.config/chezmoi/key.txt
# Configure chezmoi to use it
chezmoi edit-config
encryption = "age"
[age]
identity = "~/.config/chezmoi/key.txt"
recipient = "age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Now encrypt the sensitive files:
chezmoi add --encrypt ~/.ssh/config
The file lands encrypted in your repo. Only a machine holding the age key can decrypt it, so a public dotfiles repo stays safe.
Scripts for what a file can’t do
Sometimes a file isn’t the right tool and you need to run a command. Chezmoi supports scripts for that:
# ~/.local/share/chezmoi/run_once_install-packages.sh
#!/bin/bash
# This runs once (tracked by hash)
if command -v brew &> /dev/null; then
brew install ripgrep fd bat fzf
elif command -v apt &> /dev/null; then
sudo apt install -y ripgrep fd-find bat fzf
elif command -v pacman &> /dev/null; then
sudo pacman -S --noconfirm ripgrep fd bat fzf
fi
The prefix controls when a script runs:
run_once_runs once and never again, tracked by content hashrun_onchange_runs whenever the script’s content changesrun_runs every time
Adding mise for tool versions
Chezmoi handles dotfiles, but it says nothing about which version of Go, Node, Python or Terraform you have installed. I want those consistent across machines too. Otherwise I’m the one saying “works on my laptop”.
That’s the job of mise. It’s a polyglot version manager in the spirit of asdf, faster and a lot more pleasant to use.
Why mise and not asdf
I ran asdf for years. It works, and it has rough edges. It’s slow, because it’s built on shell scripts. Its plugin ecosystem is fragmented. And there’s no native way to keep its config in your dotfiles.
Mise fixes all three. It’s written in Rust, so it’s fast. Most tools are supported out of the box, no plugin hunting. And its config lives in ~/.config/mise/config.toml, which slots straight into chezmoi.
Install mise
# Install
curl https://mise.run | sh
# Or via package manager
brew install mise
Add it to your shell:
# ~/.zshrc
eval "$(mise activate zsh)"
Define your tools
# ~/.config/mise/config.toml
[tools]
go = "1.22"
node = "lts"
python = "3.12"
terraform = "1.7"
kubectl = "latest"
helm = "latest"
Now run:
mise install
Every tool gets installed at the pinned version. Switch machines, run mise install, and you’re looking at the same environment. That’s the part I was after.
Project-specific versions
You can also pin versions per project:
# ~/projects/legacy-app/.mise.toml
[tools]
node = "16" # Old project needs Node 16
Mise switches the moment you cd into the directory. Nothing to remember.
How the two fit together
Here’s how it looks in my own setup.
The mise config goes into chezmoi
chezmoi add ~/.config/mise/config.toml
Now my tool versions ride along with my dotfiles. Same versions everywhere, tracked in git.
Bootstrap scripts
# ~/.local/share/chezmoi/run_once_before_10-install-mise.sh
#!/bin/bash
if ! command -v mise &> /dev/null; then
curl https://mise.run | sh
fi
# ~/.local/share/chezmoi/run_once_after_90-mise-install.sh
#!/bin/bash
# Run after dotfiles are in place
mise install
The before and after in the filename control the order, so mise gets installed first and mise install runs once the config is in place.
Shell integration
My .zshrc.tmpl:
# mise
if command -v mise &> /dev/null; then
eval "$(mise activate zsh)"
fi
# ... rest of config ...
Machine-specific tools
Different machines need different tools, and the same templating handles that:
# ~/.config/mise/config.toml.tmpl
[tools]
go = "1.22"
node = "lts"
python = "3.12"
{{ if .work -}}
terraform = "1.7"
kubectl = "1.29"
helm = "3.14"
{{- end }}
{{ if eq .chezmoi.os "darwin" -}}
# macOS-specific tools
{{- end }}
A fresh machine in one line
This is the payoff. On a fresh machine the whole thing comes down to one command:
# 1. Install chezmoi and init from repo
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply yourusername
# 2. That's it
The init script:
- Clones my dotfiles repo
- Prompts for machine-specific data, work or personal, email
- Decrypts secrets, after asking for the age key
- Runs the bootstrap scripts, which install mise and packages
- Applies all dotfiles
Inside five minutes I’m in a fully configured environment, secrets included. In the bare-repo days I’d spend an hour hand-fixing configs and still find something missing a week later.
Smaller things worth knowing
External files
Need files pulled from a URL or another git repo?
# ~/.local/share/chezmoi/.chezmoiexternal.toml
[".oh-my-zsh"]
type = "archive"
url = "https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz"
exact = true
stripComponents = 1
refreshPeriod = "168h"
[".config/nvim/lazy-lock.json"]
type = "file"
url = "https://raw.githubusercontent.com/you/nvim-config/main/lazy-lock.json"
refreshPeriod = "24h"
Ignoring files
# ~/.local/share/chezmoi/.chezmoiignore
# Never sync these
.DS_Store
*.swp
.zsh_history
# Machine-specific ignores
{{ if not .work }}
.config/work-vpn/
{{ end }}
Re-add after editing
If you edit a file directly instead of going through chezmoi edit:
# Re-add to update source state
chezmoi re-add
Check for drift
Want to know whether your home directory has drifted from what chezmoi expects?
chezmoi verify
Where this leaves me
After a bare git repo, GNU Stow, an Ansible playbook and a handful of other tools, chezmoi plus mise is the setup that stuck. The first evening goes into the templates and the age key. After that, a new machine is one command, and a new tool version is an edit to config.toml, a push, and a pull on the other machines.
Is it overkill if you only ever touch one machine? Probably. If you spin up VMs, reinstall often, or want your config versioned somewhere you control, it’s worth the evening:
chezmoi init --apply yourusername
Resources:
- chezmoi.io - official documentation
- mise.jdx.dev - mise documentation
- age encryption - simple, modern encryption
