I’ve been using the same .zshrc for years. And my .vimrc. And my tmux config. Over time they’ve grown into a carefully tuned system that does exactly what I want. The problem: I have multiple machines. A laptop, a desktop, sometimes a VM for testing. And keeping everything in sync was always… improvised.
Finding the right dotfile management solution took me years. I tried everything. And I mean everything.
The Long Search
It started with the classic: a bare git repo in my home directory. git init --bare ~/.dotfiles, some aliases, done. It works, but it’s fragile. One wrong git clean and you’ve nuked your configs. And good luck with machine-specific settings.
Then I discovered GNU Stow. Elegant idea: organize your dotfiles in directories, symlink them into place. I used this for a good while. But Stow has no concept of templates. My work laptop and personal desktop need different .gitconfig settings. With Stow, you end up with gitconfig-work and gitconfig-personal and manual switching. Not great.
At some point I thought: “I already use Ansible for my servers, why not for my dotfiles?” So I built an Ansible playbook for my workstation setup. Complete with roles, templates, handlers — the works. It was beautiful. It was also massive overkill. Running ansible-playbook every time I changed my .zshrc felt ridiculous. And the YAML soup for simple file operations was painful.
I also tried:
- yadm — git-based, better than bare repo, but templating felt bolted on
- dotbot — simple YAML config, but limited flexibility
- home-manager (Nix) — powerful, but I didn’t want to go full Nix
Nothing quite clicked. Each tool solved some problems but created others.
Until I discovered chezmoi and combined it with mise. This combo has become my definitive dotfile solution. Finally.
The Problem
Dotfile management seems simple. It’s just config files, right? But things get complicated quickly:
- Machine-specific config: My laptop needs different settings than my desktop
- Secrets: My
.gitconfigcontains tokens, my.ssh/configcontains hostnames I don’t want in a public repo - Tool versions: I need Go 1.21 on one machine and 1.22 on another
- Bootstrap: On a fresh machine I want to be productive within minutes, not hours
A simple git repo doesn’t solve this. You need templating, secret management, and a bootstrap process.
Enter chezmoi
Chezmoi is a dotfile manager that solves all these problems. The name is French for “at my place” (chez moi), which is fitting — it brings your home environment everywhere.
Core Concept
Chezmoi maintains a “source state” (in ~/.local/share/chezmoi/) and applies it to your home directory. The source state can contain:
- Regular files
- Templates (with variables per machine)
- Scripts (for things that can’t be captured in files)
- Encrypted files (for secrets)
Getting Started
# 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 it. Your .zshrc is now managed by chezmoi.
The Basic Workflow
# 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
Templating: Machine-Specific Config
This is where chezmoi shines. You can use Go templates to have different config per machine.
First, define your data in ~/.config/chezmoi/chezmoi.toml:
[data]
hostname = "laptop"
work = true
email = "tom@example.com"
Then use it in templates. Rename a file with .tmpl suffix:
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 is included. On my personal machines, it isn’t.
Machine Detection
You can auto-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 use an interactive prompt on first setup:
# chezmoi.toml.tmpl
[data]
email = {{ promptString "email" | quote }}
work = {{ promptBool "work machine" }}
Secrets with age
Never commit secrets in plain text. Chezmoi integrates with age for encryption.
# 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 sensitive files:
chezmoi add --encrypt ~/.ssh/config
The file is stored encrypted in your repo. Only machines with the age key can decrypt it.
Scripts: When Files Aren’t Enough
Sometimes you need to run commands. Chezmoi supports scripts:
# ~/.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
Script naming:
run_once_— runs once, then never again (tracked by content hash)run_onchange_— runs when the script content changesrun_— runs every time
Enter mise
Chezmoi handles dotfiles, but what about tool versions? I need specific versions of Go, Node, Python, Terraform, etc. And they should be consistent across machines.
This is where mise comes in. It’s a polyglot version manager — think asdf, but faster and more ergonomic.
Why mise?
I used asdf for years. It works, but:
- Slow (shell scripts)
- Plugin ecosystem is fragmented
- No native support for config in dotfiles
Mise is:
- Fast (written in Rust)
- Built-in support for most tools
- Config lives in
~/.config/mise/config.toml— perfect for chezmoi
Getting Started with mise
# Install
curl https://mise.run | sh
# Or via package manager
brew install mise
Add 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
All tools are installed at the specified versions. When you switch machines, mise install gives you the exact same environment.
Project-Specific Versions
You can also have per-project versions:
# ~/projects/legacy-app/.mise.toml
[tools]
node = "16" # Old project needs Node 16
Mise automatically switches when you enter the directory.
The Combo: chezmoi + mise
Here’s how I combine them:
1. mise Config in chezmoi
chezmoi add ~/.config/mise/config.toml
Now my tool versions are part of my dotfiles. Same versions everywhere.
2. Bootstrap Script
# ~/.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 execution order.
3. Shell Integration
My .zshrc.tmpl:
# mise
if command -v mise &> /dev/null; then
eval "$(mise activate zsh)"
fi
# ... rest of config ...
4. Machine-Specific Tools
Different machines need different tools:
# ~/.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 }}
My Bootstrap Process
On a fresh machine:
# 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/personal, email)
- Decrypts secrets (prompts for age key)
- Runs bootstrap scripts (installs mise, packages)
- Applies all dotfiles
Within 5 minutes I have a fully configured environment.
Tips and Tricks
External Files
Need files from URLs or git repos?
# ~/.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 via chezmoi edit:
# Re-add to update source state
chezmoi re-add
Check for Drift
See if your home directory has diverged from chezmoi:
chezmoi verify
Conclusion
After years of searching — bare git repos, GNU Stow, Ansible playbooks, and half a dozen other tools — chezmoi + mise is the setup that finally sticks. It handles everything:
- Cross-machine sync via git
- Machine-specific config via templates
- Secrets via age encryption
- Tool versions via mise
- Bootstrap via scripts
The initial setup takes some time. But once it’s done, you have a reproducible development environment that follows you everywhere.
New machine? One command. New tool version needed? Update config.toml, push, pull on other machines. Done.
Is it overkill for someone with one machine? Maybe. But if you regularly set up new machines, use VMs, or just want the peace of mind that your config is versioned and backed up — give it a try.
chezmoi init --apply yourusername
Your future self will thank you.
Resources:
- chezmoi.io — official documentation
- mise.jdx.dev — mise documentation
- age encryption — simple, modern encryption
