· · 9 min read Beginner

Dotfile Management with mise and chezmoi: The Perfect Combo

How to manage dotfiles across multiple machines with chezmoi, combined with mise for tool version management. A practical guide for developers.

Three identical cases, the third unfolds into the same layout at a strange desk, a goblin holds loose scraps

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 hash
  • run_onchange_ runs whenever the script’s 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 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:

  1. Clones my dotfiles repo
  2. Prompts for machine-specific data, work or personal, email
  3. Decrypts secrets, after asking for the age key
  4. Runs the bootstrap scripts, which install mise and packages
  5. 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: