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.

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 .gitconfig contains tokens, my .ssh/config contains 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 changes
  • run_ — 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:

  1. Clones my dotfiles repo
  2. Prompts for machine-specific data (work/personal, email)
  3. Decrypts secrets (prompts for age key)
  4. Runs bootstrap scripts (installs mise, packages)
  5. 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: