Featured image of post chezmoi: One Dotfiles Repo Across macOS, Linux, and Windows

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

chezmoi is a Go-based dotfiles manager. Go templates handle machine differences, age encrypts secrets, and run_onchange scripts auto-install packages. How I sync dotfiles across three operating systems.

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

1
2
3
4
5
6
7
8
# 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:

1
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.

PrefixEffectExample
dot_Target is a hidden filedot_zshrc β†’ ~/.zshrc
private_User-only permissions (0600)private_dot_ssh β†’ ~/.ssh
executable_Sets executable bitexecutable_bin_foo
encrypted_age/gpg encryptedencrypted_dot_env
symlink_Creates a symlinksymlink_dot_bashrc
readonly_Strips write permissionsreadonly_dot_config.toml
.tmpl suffixRun through template enginedot_gitconfig.tmpl

Prefixes stack. My repo has combinations like:

1
2
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[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:

1
2
3
4
5
{{ .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:

1
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:

1
2
age-keygen -o ~/key.txt
# Public key: age1examplepublickeyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

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

1
2
3
4
5
encryption = "age"

[age]
    identity = "~/key.txt"
    recipient = "age1examplepublickeyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Add files with --encrypt:

1
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{{ 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:

PrefixWhen 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/:

1
2
3
4
5
6
7
8
9
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:

1
2
3
4
5
6
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

1
2
3
4
5
6
7
8
9
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:

  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