DEV Community

Recca Tsai
Recca Tsai

Posted on • Originally published at recca0120.github.io

chezmoi: One Dotfiles Repo Across macOS, Linux, and Windows

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:

  1. Go templates: the same file renders differently per OS, no need to maintain three .gitconfig variants
  2. Native encryption: age and gpg are first-class, so secrets can live in a public repo
  3. 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
Enter fullscreen mode Exit fullscreen mode

Bootstrap a new machine from an existing repo:

chezmoi init --apply https://github.com/YOUR_USERNAME/dotfiles.git
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

Preview a template without applying:

chezmoi execute-template < dot_gitconfig.tmpl
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Configure ~/.config/chezmoi/chezmoi.toml:

encryption = "age"

[age]
    identity = "~/key.txt"
    recipient = "age1s5xur7evyf9y9un4yt8cwqrqw9vd0k8m67kkl655gy9nmnfrgplsxef583"
Enter fullscreen mode Exit fullscreen mode

Add files with --encrypt:

chezmoi add --encrypt ~/.ssh/id_ed25519
Enter fullscreen mode Exit fullscreen mode

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 -}}
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

.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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Restore the age key
  2. sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply recca0120
  3. run_onchange scripts install CLI tools via brew / apt / scoop
  4. All configs land in place
  5. 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-template to 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 $HOME already has hand-edited dotfiles, apply overwrites them. Always chezmoi diff first

References

Top comments (0)