Originally published at recca0120.github.io
My work machine is a MacBook, my home desktop runs Linux, and the company handed me a Windows NUC. All three need synced .gitconfig, .zshrc, .tmux.conf — but each OS has quirks. Windows needs sslCAInfo pointing at scoop's git cert bundle; macOS uses Homebrew; Linux uses apt.
I used to hack it with symlinks and shell scripts. Now I use chezmoi. One dotfiles repo, three machines, one-line setup.
Why Not stow, yadm, or dotbot
Plenty of dotfiles managers exist. chezmoi wins on three fronts:
-
Go templates: the same file renders differently per OS, no need to maintain three
.gitconfigvariants - Native encryption: age and gpg are first-class, so secrets can live in a public repo
- onchange scripts: the Homebrew bootstrap only re-runs when the package list actually changes
stow is pure symlinks, no templating. yadm wraps git, templating via plugins. dotbot needs a YAML manifest. chezmoi bundles it all into one binary.
Install and Init
# macOS
brew install chezmoi
# Linux
sh -c "$(curl -fsLS get.chezmoi.io)"
# Windows
winget install twpayne.chezmoi
Bootstrap a new machine from an existing repo:
chezmoi init --apply https://github.com/YOUR_USERNAME/dotfiles.git
That single line clones the repo, runs the template engine, and writes everything to $HOME.
Filename Attribute System
chezmoi uses filename prefixes to encode behavior. The repo layout itself is the manifest — no separate config needed.
| Prefix | Effect | Example |
|---|---|---|
dot_ |
Target is a hidden file |
dot_zshrc → ~/.zshrc
|
private_ |
User-only permissions (0600) |
private_dot_ssh → ~/.ssh
|
executable_ |
Sets executable bit | executable_bin_foo |
encrypted_ |
age/gpg encrypted | encrypted_dot_env |
symlink_ |
Creates a symlink | symlink_dot_bashrc |
readonly_ |
Strips write permissions | readonly_dot_config.toml |
.tmpl suffix |
Run through template engine | dot_gitconfig.tmpl |
Prefixes stack. My repo has combinations like:
private_executable_dot_php-cs-fixer.dist.php → ~/.php-cs-fixer.dist.php (0700)
private_dot_ssh/ → ~/.ssh (whole dir at 0700)
Templates for Machine Differences
This is chezmoi's killer feature. My dot_gitconfig.tmpl:
[user]
name = {{ .name | quote }}
email = {{ .email | quote }}
[http]
sslBackend = openssl
{{ if eq .chezmoi.os "windows" -}}
sslCAInfo = {{- .chezmoi.homeDir | replace "\\" "/" -}}/scoop/apps/git/current/mingw64/ssl/certs/ca-bundle.crt
{{ end }}
[core]
autocrlf = false
symlinks = true
.name and .email come from ~/.config/chezmoi/chezmoi.toml, so each machine can have its own values. The {{ if eq .chezmoi.os "windows" }} block only expands on Windows. On apply, chezmoi strips the .tmpl and writes a clean .gitconfig.
Built-in variables I reach for constantly:
{{ .chezmoi.os }} # "darwin" / "linux" / "windows"
{{ .chezmoi.arch }} # "amd64" / "arm64"
{{ .chezmoi.hostname }} # machine name
{{ .chezmoi.username }} # login user
{{ .chezmoi.homeDir }} # home directory
Preview a template without applying:
chezmoi execute-template < dot_gitconfig.tmpl
Age Encryption for Secrets
My repo is public, but it contains an SSH key and database password backups. Those are encrypted with age before they ever hit a commit.
Generate an age key:
age-keygen -o ~/key.txt
# Public key: age1s5xur7evyf9y9un4yt8cwqrqw9vd0k8m67kkl655gy9nmnfrgplsxef583
Configure ~/.config/chezmoi/chezmoi.toml:
encryption = "age"
[age]
identity = "~/key.txt"
recipient = "age1s5xur7evyf9y9un4yt8cwqrqw9vd0k8m67kkl655gy9nmnfrgplsxef583"
Add files with --encrypt:
chezmoi add --encrypt ~/.ssh/id_ed25519
The repo only ever stores private_dot_ssh/encrypted_private_id_ed25519.age — opaque ciphertext. On apply, chezmoi decrypts using ~/key.txt and writes the plaintext to the target.
The one catastrophic footgun: key.txt itself must never land in the repo. My workflow: GPG-encrypt it and stash it in a password manager. New machines must restore key.txt manually before running chezmoi init --apply.
run_onchange: Reinstall Only When Lists Change
My .chezmoiscripts/darwin/run_onchange_00_install-packages.sh.tmpl:
{{ if eq .chezmoi.os "darwin" -}}
#!/bin/bash
brew install mas
brew install asdf
asdf plugin add nodejs
asdf install nodejs latest
asdf set nodejs latest
# ... many more asdf installs
{{ end -}}
The run_onchange_ prefix is the key: chezmoi only runs this script when its content hash changes. Unchanged package list means no re-run — no more five-minute brew install cycles through already-installed tools on every chezmoi apply.
Script naming variants:
| Prefix | When It Runs |
|---|---|
run_once_ |
Once per machine, ever, for given content |
run_onchange_ |
Whenever the contents change |
run_onchange_before_ |
Before file application (install package manager first) |
run_onchange_after_ |
After file application (enable fish plugins last) |
The numeric prefix (00_, 01_, 02_) controls execution order.
.chezmoiroot: Source Lives in a Subdirectory
All my files live under home/:
dotfiles/
├── .chezmoiroot # contains just "home"
├── Readme.md
├── install.sh
├── install.ps1
└── home/
├── dot_zshrc.tmpl
├── dot_gitconfig.tmpl
└── .chezmoiscripts/
.chezmoiroot tells chezmoi "source files are under home/". Now the repo root can host a README, install scripts, and other project artifacts without chezmoi trying to apply them as dotfiles.
Great for treating your dotfiles repo like a normal project.
.chezmoiignore: Skip Certain Files
Same syntax as .gitignore, but it supports templates:
README.md
LICENSE
{{ if ne .chezmoi.os "darwin" }}
.aerospace.toml
Library/
{{ end }}
Non-macOS machines skip the aerospace window manager config and the Library folder.
Command Cheat Sheet
chezmoi add ~/.vimrc # track an existing file
chezmoi add --encrypt ~/.env # track encrypted
chezmoi edit ~/.zshrc # edit the source file directly
chezmoi diff # show pending changes
chezmoi apply # write to $HOME
chezmoi apply --dry-run -v # preview without writing
chezmoi cd # jump to source directory
chezmoi update # git pull + apply
chezmoi doctor # check environment health
chezmoi doctor reports the status of encryption tools, template engine, git, and friends. First thing to run when a new machine misbehaves.
Combined with zoxide, fish, and More
My fish config, zoxide init, tmux plugins — all managed by chezmoi. New machine ritual:
- Restore the age key
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply recca0120- run_onchange scripts install CLI tools via brew / apt / scoop
- All configs land in place
- Open fish — zoxide, starship, fzf are already wired up
About 20 minutes end to end, most of it waiting on brew install downloads.
Downsides and Gotchas
chezmoi isn't free of friction:
-
Template learning curve: Go template syntax isn't beginner-friendly. Whitespace handling with
{{- }}vs.{{ }}takes practice -
Painful debugging: template expansion errors are terse — I lean on
chezmoi execute-templateto isolate problems - Age key stewardship: lose the key, lose every encrypted file forever. Back it up separately (I GPG-encrypt and park it in a password manager)
-
First apply is destructive: if
$HOMEalready has hand-edited dotfiles, apply overwrites them. Alwayschezmoi difffirst
Top comments (0)