Skip to main content

Command Palette

Search for a command to run...

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

Updated
6 min read

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

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.

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:

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

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

encryption = "age"

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

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:

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

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:

  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

More from this blog

recca0120

115 posts