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.

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 .gitconfig carries tokens and my .ssh/config holds 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 changes
  • run_ - 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:

  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

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: