My .zshrc is older than some of my coworkers’ careers. Same with my .vimrc and my tmux config. They’ve grown into a carefully tuned system that does exactly what I want, and I’d be lost without them. The problem is that I run multiple machines. A laptop, a desktop, sometimes a throwaway VM for testing. Keeping all of that in sync used to be a pile of improvised hacks held together by hope.
The core value here is portability. I want to be effective on any machine I touch, not helpless the second I’m away from my own keyboard. Finding a dotfile setup that actually delivered that took me years of dead ends.
The long search
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 right up until it doesn’t. One careless git clean and you’ve nuked your own configs. Machine-specific settings? You’re on your own.
Then I found GNU Stow. The idea is genuinely nice: organise your dotfiles into directories and symlink them into place. I ran it for a long stretch. But Stow has no concept of templates, and my work laptop and personal desktop need different .gitconfig settings. With Stow you end up with gitconfig-work and gitconfig-personal and a manual switch you’ll forget to flip. Annoying.
At one point I thought, “I already run Ansible for my servers, why not for my dotfiles too?” 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 tweaked my .zshrc felt absurd, and writing YAML soup to copy a single file was its own special pain.
I tried a few more along the way:
- yadm - git-based, a step up from the bare repo, but the templating felt bolted on
- dotbot - clean YAML config, not flexible enough for what I wanted
- home-manager (Nix) - very powerful, but I wasn’t ready to go all-in on Nix
Nothing stuck. Every tool fixed one thing and handed me a new problem.
Then I landed on chezmoi and paired it with mise. That combo is the setup I’ve actually kept. This post walks through it from the simplest possible version up to my real bootstrap.
Why this is harder than it looks
On paper, dotfile management is trivial. They’re just config files, right? The trouble starts the moment you have more than one machine:
- Machine-specific config: my laptop wants different settings than my desktop
- Secrets: my
.gitconfigcarries tokens and my.ssh/configholds hostnames I don’t want sitting in a public repo - Tool versions: I need Go 1.21 on one box and 1.22 on another
- Bootstrap: on a fresh machine I want to be productive in minutes, not spend an afternoon copying files around
A plain git repo handles none of that. You need templating, secret handling, and a repeatable bootstrap. That’s exactly the gap chezmoi fills.
The simplest version: chezmoi
Chezmoi is a dotfile manager that covers all of the above. The name is French for “at my place” (chez moi), which fits, since it carries your home environment everywhere you go.
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 per-machine variables)
- Scripts (for things you can’t capture as a static file)
- Encrypted files (for secrets)
You understand exactly what gets written where, which is the whole point. No black box deciding things on your behalf.
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 the whole minimum viable setup. Your .zshrc is now managed by chezmoi, and you can stop here if that’s all you need.
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
Layer 1: machine-specific config with templates
This is where chezmoi earns its keep. You can use Go templates to render different config per machine from a single source.
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 gets rendered in. On my personal machines it stays out. One file, two outcomes, no manual switching to forget.
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" }}
Layer 2: secrets with age
Never commit secrets in plain text. Chezmoi integrates with age for encryption, so your 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 sensitive files:
chezmoi add --encrypt ~/.ssh/config
The file lands encrypted in your repo. Only machines holding the age key can decrypt it, so a public dotfiles repo stays safe.
Scripts: when files aren’t enough
Sometimes a file isn’t the right tool and you need to run a command. Chezmoi supports scripts for exactly 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, then never again (tracked by content hash)run_onchange_- runs whenever the script 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 “works on my laptop” becomes a recurring joke at my own expense.
That’s the job of mise. It’s a polyglot version manager in the spirit of asdf, but faster and a lot more pleasant to use.
Why mise?
I ran asdf for years. It works, but it has rough edges:
- Slow, since it’s built on shell scripts
- A fragmented plugin ecosystem
- No native way to keep its config in your dotfiles
Mise fixes all three:
- Fast, written in Rust
- Built-in support for most tools out of the box
- Config lives in
~/.config/mise/config.toml, which slots straight into 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
Every tool gets installed at the pinned version. Switch machines, run mise install, and you’re looking at the exact same environment. That reproducibility is the thing I care about.
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 automatically the moment you cd into the directory. No thinking required, which is the whole goal: less friction, more flow.
The full picture: chezmoi + mise together
Here’s how the two fit together in my actual setup.
1. mise config in chezmoi
chezmoi add ~/.config/mise/config.toml
Now my tool versions ride along with my dotfiles. Same versions everywhere, tracked in git.
2. 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 execution order, so mise gets installed first and mise install runs once the config is actually in place.
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, and templating handles that here too:
# ~/.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
This is the payoff. On a fresh machine, the whole thing comes down to one line:
# 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
Inside five minutes I’m sitting in a fully configured environment, secrets and all. Compare that to the bare-repo days where I’d be hand-fixing configs for an hour and still discover something missing a week later.
Tips and tricks
External files
Need files pulled from URLs or other 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 going through chezmoi edit:
# Re-add to update source state
chezmoi re-add
Check for drift
Want to know if your home directory has drifted away from what chezmoi expects? Verifying drift keeps you honest:
chezmoi verify
Where this leaves me
After years of bare git repos, GNU Stow, Ansible playbooks, and a handful of other tools, chezmoi plus mise is the setup that finally stuck. Looking back at the three-line install from the start of this post, here’s everything it now covers:
- Cross-machine sync via git
- Machine-specific config via templates
- Secrets via age encryption
- Tool versions via mise
- Bootstrap via scripts
The first setup costs you an evening. After that you’ve got a reproducible development environment that follows you onto any machine, which is portability in the most literal sense.
New machine? One command. Need a new tool version? Update config.toml, push, pull on the other machines, done.
Is it overkill if you only ever touch one machine? Probably. But if you spin up VMs, reinstall often, or just want the quiet confidence that your config is versioned and backed up somewhere you control, it’s worth the evening:
chezmoi init --apply yourusername
You’ll be glad you did the next time a machine dies on you.
Resources:
- chezmoi.io - official documentation
- mise.jdx.dev - mise documentation
- age encryption - simple, modern encryption
