Your home directory,
declared once.
chezmoi generates every machine's dotfiles from one versioned source of truth — templated per machine, secrets pulled from KeePassXC at apply time, never symlinked.
1 · Mental model: a compiler for your home directory
chezmoi is not a smarter symlink farm. It is a build system whose input is a versioned source directory, whose build step merges templates with machine-specific data, and whose output is real files in your home directory. Once you see apply as compilation, every command falls into place.
Three states exist at all times, and every chezmoi command is a movement between them:
- The source state — what your dotfiles repo declares. Lives in
~/.local/share/chezmoias a git repository. - The target state — the desired result, computed by rendering templates with your machine's data. It exists only in memory.
- The destination state — what is actually sitting in
~right now.
apply writes target → destination, add/re-add pull destination → source, diff/status compare without writing.Declare
Edit the source state — filenames encode metadata (dot_, private_), templates encode differences between machines.
Preview
chezmoi diff shows exactly what apply would change. You always get to look before anything is written.
Converge
chezmoi apply makes the destination match the target state — files, permissions, symlinks, and scripts, in a defined order.
~. Nothing at runtime points back into the repo. That is what makes encrypted files, per-machine templates, and private permissions possible — a symlink cannot be a rendered template. The tradeoff is honest: your edit is not live until apply. The docs' answer for people who want immediacy is chezmoi edit --watch.The wrong question: "which dotfiles manager is best?"
The right question is what problem you actually have. If your dotfiles are identical everywhere and contain no secrets, a bare git repo works. The moment you have two machines that differ, or one secret, the comparison changes:
| Approach | Mechanism | Per-machine differences | Secrets | Where it breaks |
|---|---|---|---|---|
| chezmoi | Generate real files from versioned source | Templates, per-machine data, .chezmoiignore | Password-manager functions, age/gpg encryption | Extra concept load: attributes, templates, two-step edit→apply |
Bare git repo in $HOME | Track files in place | Branches per machine (merge pain grows forever) | None — plaintext or manual hacks | One accidental git clean away from disaster; no metadata |
| GNU Stow | Symlink farm from a packages dir | Separate packages per machine, manual | None | Symlinks break tools that resolve paths; no templating; no perms control |
| yadm | Git wrapper + alternates + templates | Alternate files per OS/host | Built-in encryption | Still edits live files in ~; no dry-run compile step |
Recommendation: you already track machine-specific differences (a Mac and, sooner or later, a Linux box), and you want KeePassXC-backed secrets. That is exactly chezmoi's home territory. Commit to it fully — a half-adopted chezmoi (your current state) gives you the concept overhead with none of the payoff.
Sources: chezmoi design FAQ · why use chezmoi
2 · Prerequisite floor
chezmoi leans on three things you mostly have already. Check yourself honestly — gaps here surface later as mysterious template errors and git confusion, not as clear failures.
Git — hard
You must be able to commit, push, pull, and read a diff. chezmoi's source directory is a git repo; chezmoi update is literally git pull + apply. You do not need rebase fluency.
Shell — hard
Comfortable editing .zshrc, understanding $PATH, file permissions (chmod 600), and what a symlink is.
Go templates — just-in-time
Not required up front. §7 teaches the four constructs that cover 95% of real dotfiles: {{ .var }}, if/else, eq, and comments. Learn them there.
Self-assessment
- Can you explain what
git statusvsgit diffshows? (If not: any git primer before §8.) - Do you know why
~/.netrcshould be mode 600 while~/.zshrcneed not be? - Can you predict what
ls -lshows for a symlink? - Have you used
mktemp -d? (Labs use it constantly to stay disposable.)
Three or more boxes checked: proceed. Fewer: still proceed — the labs are sandboxed so mistakes are free — but expect to look things up.
3 · Install, verify, and build a safe sandbox
One habit carries this entire guide: chezmoi accepts --source and --destination flags, so you can run a complete, fully isolated chezmoi playground in a throwaway directory. Your real dotfiles never enter the picture until you decide they should.
brew install chezmoi
chezmoi --version
chezmoi version v2.72.0, commit Homebrew, built at 2026-08-02T18:45:49Z, built by Homebrew
On a machine without Homebrew, the official one-liner installs the latest binary (and §8 shows the variant that bootstraps your whole setup in the same breath):
sh -c "$(curl -fsLS https://get.chezmoi.io)"
Create an isolated lab directory and a wrapper script cz that pins chezmoi to it. Every later lab uses cz; your real home directory and real source directory are untouched.
LAB=$(mktemp -d /tmp/chezmoi-lab.XXXX)
mkdir -p $LAB/src $LAB/home $LAB/cfg
cat > $LAB/cz <<EOF
#!/bin/sh
exec chezmoi --source $LAB/src --destination $LAB/home --config $LAB/cfg/chezmoi.toml "\$@"
EOF
chmod +x $LAB/cz
$LAB/cz init
ls -la $LAB/src
drwxr-xr-x .git # init created an empty git repo — that IS the source state
initwith no argument creates an empty source repo. (With a repo URL it clones instead — §8.)- Run
$LAB/cz doctor. Expect a table ofokrows: version, latest-version, config-file, source-dir "is a git working tree (clean)".doctoris your first stop whenever anything misbehaves. - Nothing outside
$LABchanged. Verify withchezmoi source-path(no flags) — it still points at your real~/.local/share/chezmoi.
dot_zshrc, dot_gitconfig, and a fully-copied dot_oh-my-zsh. The fastest route to finishing that adoption confidently is drilling each mechanism where breakage is free, then executing the capstone (§12) against the real thing.Sources: install · command-line flags
4 · State-flow simulator
Before touching more commands, internalize the loop. This simulator models two files across the source and destination states. Click commands; watch versions converge and diverge. Simplified on purpose: real chezmoi tracks a third record — the last state it wrote — which is how status tells "you edited the source" ( M) from "someone edited the real file too" (MM). The simulator models that as well.
source · ~/.local/share/chezmoi
destination · ~
- Press edit then status:
M .zshrc— source moved ahead, destination stale. - Press apply: versions converge, status goes clean.
- Press vim by hand then status:
MMwould appear only after the source also moves — try edit too, then decide: apply (source wins) or re-add (your hand edit wins). That decision is the whole daily discipline.
5 · Adopt your dotfiles: the daily loop
Four commands are the whole daily workflow: add to bring a file under management, edit to change it, diff to preview, apply to converge. Everything else is refinement.
Your CLI instincts, translated
| Old instinct | chezmoi move | Why it is better |
|---|---|---|
vim ~/.zshrc | chezmoi edit --apply ~/.zshrc | Edits the source copy, applies on quit. The change is captured for every machine, not just this one. |
cp .zshrc backups/ | chezmoi add ~/.zshrc + git commit | Versioned history with diffs, not timestamped copies. |
| "what did I change?" | chezmoi diff / chezmoi status | Compares desired vs actual state, including permissions — not just contents. |
| scp configs to new machine | chezmoi init --apply $GITHUB_USERNAME | One command reproduces the whole home directory, templates resolved for that machine. |
| edited the real file directly (habit) | chezmoi re-add | Pulls your hand edit back into the source instead of losing it at next apply. |
# A fake dotfile in the sandbox "home"
printf 'export EDITOR=vim\nalias ll="ls -la"\n' > $LAB/home/.zshrc
$LAB/cz add $LAB/home/.zshrc
ls $LAB/src
dot_zshrc # leading dot became the dot_ prefix
$LAB/cz managed
.zshrc
Now change the source copy (this is what chezmoi edit does under the hood — it opens the source file in $EDITOR):
printf 'export EDITOR=nvim\nalias ll="ls -la"\n' > $LAB/src/dot_zshrc
$LAB/cz status
M .zshrc
$LAB/cz diff
--- a/.zshrc
+++ b/.zshrc
@@ -1,2 +1,2 @@
-export EDITOR=vim
+export EDITOR=nvim
alias ll="ls -la"
$LAB/cz apply -v
cat $LAB/home/.zshrc
export EDITOR=nvim
alias ll="ls -la"
statusafter apply prints nothing — clean means converged.- Now edit the destination by hand:
echo 'alias gs="git status"' >> $LAB/home/.zshrc, then runstatus. ExpectMM .zshrconce the source has also changed, or a single right-columnMwhen only the real file moved. - Run
$LAB/cz re-add. Status is clean again andgrep gs $LAB/src/dot_zshrcfinds your alias — the hand edit was captured, not clobbered.
The edit ergonomics you will actually use
chezmoi edit ~/.zshrc— edit the source copy; apply separately.chezmoi edit --apply ~/.zshrc— apply automatically when you quit the editor. Recommended default.chezmoi edit --watch ~/.zshrc— apply on every save. Closest to editing the live file.chezmoi cd— open a subshell in the source directory for git work or bulk edits. Exit returns you home.chezmoi forget ~/.hushlogin— stop managing a file (source entry removed, real file left in place).chezmoi destroydeletes both — almost never what you want.
chezmoi source-path ~/.zshrc prints exactly where; you can open that file with any editor. There is no database for file contents — only a small state boltdb for script bookkeeping (§9).Sources: daily operations · command overview
6 · The attribute grammar: filenames as declarations
chezmoi stores metadata where git can version it: in the filename. private_dot_netrc is not a naming convention — it is a small declarative language. Read the prefixes off and you know the target path, permissions, and type without opening the file.
| Prefix | Declares | Verified behavior (v2.72.0) |
|---|---|---|
dot_ | Target name starts with . | dot_zshrc → .zshrc |
private_ | Strip group/world permissions | private_dot_netrc applied as -rw------- (600) — seen in the apply diff as new file mode 100600 |
executable_ | Add execute bit | Applied as -rwxr-xr-x |
readonly_ | Strip write permissions | Combine: private_readonly_ → 400 |
symlink_ | Target is a symlink; file contents are the link destination | symlink_dot_theme.conf.tmpl containing a path template produced .theme.conf → /Users/you/… |
create_ | Create with these contents only if absent; never overwrite after | Hand-edited the applied file, re-ran apply: edit survived, status stayed clean |
modify_ | Contents are a script that transforms the existing file (stdin → stdout) | For files partly owned by other software |
empty_ | Keep the file even when empty | Zero-byte files are otherwise removed from the target state |
exact_ (dirs) | Delete anything in the directory chezmoi does not manage | Use for config dirs that must contain nothing stray |
remove_ | Remove the target if present | Declarative deletion |
encrypted_ | Contents are encrypted in the source (§10) | encrypted_dot_secret-key.age |
literal_ / .literal | Stop attribute parsing | For real files whose names begin with dot_ etc. |
.tmpl (suffix) | Render contents as a Go template (§7) | dot_gitconfig.tmpl |
Change attributes with chezmoi chattr rather than renaming by hand — it renames the source file for you and understands stacking order:
$LAB/cz chattr private $LAB/home/.zshrc # dot_zshrc → private_dot_zshrc
$LAB/cz chattr noprivate $LAB/home/.zshrc # and back
$LAB/cz chattr +template $LAB/home/.gitconfig # append .tmpl
printf 'secret-ish config\n' > $LAB/src/private_dot_netrc
$LAB/cz apply -v
diff --git a/.netrc b/.netrc
new file mode 100600 # ← private_ became 600 before your eyes
ls -l $LAB/home/.netrc
-rw------- .netrc
# A create_ file: applied once, then hands off
printf 'default content\n' > $LAB/src/create_dot_hushlogin
$LAB/cz apply
echo "edited-by-user" > $LAB/home/.hushlogin
$LAB/cz apply && cat $LAB/home/.hushlogin
edited-by-user # create_ never clobbers an existing file
- Add an
executable_helloscript under$LAB/src/dot_local/bin/and apply; expect-rwxr-xr-xon~/.local/bin/hello. - Try creating both
dot_configandprivate_dot_configdirectories in the source, then apply. Expect, honestly:chezmoi: .config: inconsistent state— two source entries map to one target. One target, one source entry; put attributes on that single entry. Delete one to fix it.
apply stops and asks before overwriting — in a script or CI that surfaces as could not open a new TTY. chezmoi apply --force overrides; chezmoi re-add keeps the out-of-band edit instead. Decide, don't reflexively force.Sources: source state attributes · chattr
7 · Templates & machine differences
Templates are why chezmoi beats copying files around: one source file, many rendered results. The engine is Go's text/template; the data is a merged dictionary you can inspect at any time with chezmoi data.
Where data comes from (all merged)
- Built-ins —
.chezmoi.os("darwin"/"linux"),.chezmoi.arch,.chezmoi.hostname,.chezmoi.homeDir, and friends. - Your config file —
~/.config/chezmoi/chezmoi.toml,[data]section. Per-machine, not in the repo. This is the escape hatch for anything machine-specific. .chezmoidata.toml(or .yaml/.json) in the source root — shared data, versioned in the repo.- Secret functions —
keepassxcand friends fetch at render time (§10).
# dot_gitconfig.tmpl — one file, correct on every machine
[user]
email = {{ .email }}
[core]
editor = {{ if eq .chezmoi.os "darwin" }}nvim{{ else }}vim{{ end }}
cat > $LAB/cfg/chezmoi.toml <<'EOF'
[data]
email = "you@example.com"
machine = "sandbox"
EOF
cat > $LAB/src/.chezmoidata.toml <<'EOF'
[colors]
theme = "cobalt"
EOF
# write dot_gitconfig.tmpl as above, plus one line using .machine and .colors.theme
$LAB/cz data --format json | head # the merged dictionary, truthfully
$LAB/cz cat $LAB/home/.gitconfig # render target state WITHOUT applying
[user]
email = you@example.com
[core]
editor = nvim
$LAB/cz execute-template '{{ .chezmoi.os }}/{{ .chezmoi.arch }}'
darwin/amd64
Now the per-OS ignore. .chezmoiignore is itself a template, and the logic is inverted — you ignore unless the condition holds:
cat > $LAB/src/.chezmoiignore <<'EOF'
{{ if ne .chezmoi.os "linux" }}
.config/systemd
{{ end }}
EOF
mkdir -p $LAB/src/dot_config/systemd
printf 'linux only\n' > $LAB/src/dot_config/systemd/unit.conf
$LAB/cz apply
ls $LAB/home/.config
# empty on darwin — systemd/ was ignored
chezmoi catandexecute-templateare your template debuggers. Use them beforeapply, every time you write a template.- Change
editor's condition toeq .chezmoi.hostname "macbook"and re-render — hostname-based switching works identically.
Patterns that scale
- Small differences → inline
{{ if }}blocks in one template. - Whole-file differences →
{{ if eq .chezmoi.os "darwin" }}{{ include ".zshrc_darwin" }}{{ end }}, or ignore the file entirely per machine in.chezmoiignore. - Shared blocks → put fragments in
.chezmoitemplates/and pull them in with{{ template "fragment" . }}. - Machine identity → prefer a
work = true/falseflag in each machine's config[data]over hostname string-matching; hostnames change, roles don't.
apply time, once. The rendered .zshrc is plain text; there is no chezmoi at shell startup. If data changes, nothing updates until the next apply. Corollary from the sandbox: .chezmoi.homeDir is the OS user's home, not the --destination override — in sandbox templates, prefer .chezmoi.destDir if you mean the destination.Sources: machine-to-machine differences · templating · template variables
8 · The git layer and multi-machine sync
chezmoi does not reinvent sync. The source directory is a git repo; GitHub (or any remote) is the transport; chezmoi update is git pull --autostash --rebase followed by apply. If you can push and pull, you can sync machines.
init by .chezmoi.toml.tmpl.Publishing (machine A, once)
chezmoi cd # subshell inside the source repo
git remote add origin git@github.com:$GITHUB_USERNAME/dotfiles.git
git push -u origin main
exit
Dotfiles with secrets-via-KeePassXC contain no secret material (§10), but a private repo is still the sane default — your shell history habits, hostnames, and internal paths are nobody's business.
Bootstrapping (machine B, any time)
# chezmoi already installed:
chezmoi init --apply $GITHUB_USERNAME # expands to your dotfiles repo
# bare metal — install AND converge in one line:
sh -c "$(curl -fsLS https://get.chezmoi.io)" -- init --apply $GITHUB_USERNAME
Ask once per machine: .chezmoi.toml.tmpl
A file named .chezmoi.toml.tmpl in the source root is special: chezmoi init renders it to create the machine's config file. Prompt functions make bootstrap interactive exactly once:
# .chezmoi.toml.tmpl (in the repo)
{{ $email := promptString "email" -}}
{{ $work := promptBool "is this a work machine" -}}
[data]
email = {{ $email | quote }}
work = {{ $work }}
promptStringOnceIt reuses the existing answer when you re-run chezmoi init after changing the template, instead of asking again. When the config template changes upstream, chezmoi warns: config file template has changed, run chezmoi init to regenerate config file — that warning is routine, not an error.No GitHub account is touched: a local bare repo plays the remote perfectly.
# Publish machine A (your Lab 1-4 sandbox)
cd $LAB/src
git add -A && git commit -m "dotfiles v1"
git init --bare $LAB/origin.git
git remote add origin $LAB/origin.git && git push origin HEAD:main
# "Machine B": fresh source + home + config
mkdir -p $LAB/home2 $LAB/cfg2
chezmoi --source $LAB/src2 --destination $LAB/home2 \
--config $LAB/cfg2/chezmoi.toml init $LAB/origin.git
Cloning into '…/src2'... done.
# give machine B its own data, then converge
printf '[data]\n email = "you@example.com"\n machine = "machine2"\n' > $LAB/cfg2/chezmoi.toml
chezmoi --source $LAB/src2 --destination $LAB/home2 --config $LAB/cfg2/chezmoi.toml apply
[script] installing packages (runs once) # scripts fire fresh per machine (§9)
ls -A $LAB/home2
.config .gitconfig .local .netrc .theme.conf .zshrc
Round-trip a change:
# A: change, commit, push
printf 'alias reload="exec zsh"\n' >> $LAB/src/dot_zshrc
cd $LAB/src && git commit -am "add reload alias" && git push origin HEAD:main
# B: one command pulls AND applies
chezmoi --source $LAB/src2 --destination $LAB/home2 --config $LAB/cfg2/chezmoi.toml update -v
Fast-forward dot_zshrc | 1 +
+alias reload="exec zsh"
tail -1 $LAB/home2/.zshrcshows the new alias — pushed on A, live on B, one command each side.- Cautious variant when you don't trust the incoming change:
chezmoi git pull -- --autostash --rebase, thenchezmoi diff, thenapply.
Frictionless commits
If committing every tweak feels like ceremony, let chezmoi do it:
# in your config file
[git]
autoCommit = true # commit after each source change
autoPush = true # …and push (implies autoCommit)
Recommendation: autoCommit yes, autoPush no. Auto-generated commit messages are a fair price for never forgetting to commit; auto-push removes your last review point before other machines can pull a mistake.
Sources: setup · daily operations · .chezmoi.$FORMAT.tmpl
9 · Scripts & automation: the run_ family
Dotfiles describe files; machines also need actions — install packages, set macOS defaults, build a font cache. chezmoi models actions as scripts in the source state whose filename declares when they run.
| Name pattern | Runs | Use for |
|---|---|---|
run_once_*.sh | Once per unique contents, ever (per machine) | Package installs, one-time setup |
run_onchange_*.sh | Whenever its rendered contents change | "Reload X when its config changes" |
run_*.sh | Every apply | Rare — keep idempotent and fast |
run_before_* / run_after_* | Before / after files are updated | Ordering around the file pass |
Apply order is fixed and worth memorizing: run_before_ scripts (alphabetical) → files and directories (alphabetical by target name) → run_after_ scripts (alphabetical). Scripts without before/after run in the file pass, interleaved alphabetically.
cat > $LAB/src/run_once_install-packages.sh <<'EOF'
#!/bin/sh
echo "[script] installing packages (runs once)"
EOF
cat > $LAB/src/run_onchange_reload-config.sh.tmpl <<'EOF'
#!/bin/sh
# hash of .gitconfig: {{ include "dot_gitconfig.tmpl" | sha256sum }}
echo "[script] config changed, reloading"
EOF
$LAB/cz apply
[script] installing packages (runs once)
[script] config changed, reloading
$LAB/cz apply
# silence — both satisfied
printf '# comment\n' >> $LAB/src/dot_gitconfig.tmpl
$LAB/cz apply
[script] config changed, reloading # hash line changed → onchange fired
- The
sha256sumcomment is the canonical trick: embedding a hash of another file makes "run when that file changes" out of "run when I change". - Peek at the bookkeeping:
$LAB/cz state dump— script hashes live underscriptStatein a small boltdb. Delete state (chezmoi state delete-bucket --bucket=scriptState) and once-scripts run again. That is the whole mechanism; nothing hidden.
Real-world script patterns for your goals
# run_onchange_darwin-defaults.sh.tmpl — macOS settings as code
{{ if eq .chezmoi.os "darwin" -}}
#!/bin/sh
defaults write com.apple.dock autohide -bool true
killall Dock
{{ end -}}
# run_onchange_install-packages.sh.tmpl — Brewfile-driven, re-runs when the list changes
{{ if eq .chezmoi.os "darwin" -}}
#!/bin/sh
# Brewfile hash: {{ include "Brewfile" | sha256sum }}
brew bundle --file=- <<'BREWS'
brew "fzf"
brew "lazygit"
brew "age"
BREWS
{{ end -}}
chezmoi update on sensitive machines (chezmoi git pull + chezmoi diff first), and keep scripts idempotent — apply may run them at surprising times (fresh machines run every run_once_ in one burst, as Lab 5 showed).Sources: use scripts to perform actions · application order · state
10 · Secrets: KeePassXC first, age for files
The rule with no exceptions: secret material never enters the git repo. chezmoi offers two clean mechanisms — reference secrets from a password manager at render time, or encrypt whole files in the source. You want both: KeePassXC for values, age for the occasional whole secret file.
KeePassXC: values at render time
chezmoi shells out to keepassxc-cli show and exposes entries to templates. Configure once:
# ~/.config/chezmoi/chezmoi.toml
[keepassxc]
database = "/Users/you/secrets.kdbx"
# dot_config/private_gh-token.tmpl — the repo stores only this reference
GITHUB_TOKEN={{ (keepassxc "github-token").Password }}
USER={{ (keepassxc "github-token").UserName }}
Three template functions cover the entry surface: keepassxc "title" returns the entry (fields via .UserName, .Password, .URL, .Notes), keepassxcAttribute "title" "attr" reads one attribute (including custom ones), keepassxcAttachment "title" "name" reads a file attachment.
/dev/tty — not stdin, which is why headless scripts fail with could not open a new TTY), then caches the unlock for the rest of that single chezmoi run. One apply, one prompt, regardless of how many templates reference entries. Set prompt = false plus args = ["--no-password", …] only for databases that genuinely have no password, e.g. key-file-protected ones.# Throwaway database (key file instead of password → non-interactive labs)
head -c 64 /dev/urandom > $LAB/kdbx-keyfile
keepassxc-cli db-create -k $LAB/kdbx-keyfile $LAB/secrets.kdbx
Successfully created new database.
printf 'ghp_sandboxtoken123\n' | keepassxc-cli add --no-password -k $LAB/kdbx-keyfile \
-u you -p $LAB/secrets.kdbx github-token
Successfully added entry github-token.
# Wire it into the sandbox config
cat >> $LAB/cfg/chezmoi.toml <<EOF
[keepassxc]
database = "$LAB/secrets.kdbx"
args = ["--no-password", "--key-file", "$LAB/kdbx-keyfile"]
prompt = false
EOF
$LAB/cz execute-template '{{ (keepassxc "github-token").UserName }}'
you
# The private_ + .tmpl combination: rendered secret, 600 perms
mkdir -p $LAB/src/dot_config
printf 'GITHUB_TOKEN={{ (keepassxc "github-token").Password }}\n' \
> $LAB/src/dot_config/private_gh-token.tmpl
$LAB/cz apply -v
new file mode 100600
+GITHUB_TOKEN=ghp_sandboxtoken123
grep -r ghp_ $LAB/srcfinds nothing — the repo holds a reference, the vault holds the value, only the rendered target holds plaintext (at 600).- On your real setup you'll keep the password prompt (skip
prompt = false); expect exactly one prompt per apply.
age: whole encrypted files in the repo
For things that are files — an SSH key, a kubeconfig — encrypt them into the source state. chezmoi bundles age encrypt/decrypt, but not key generation: install the real thing (brew install age, tested v1.3.1) for age-keygen.
age-keygen -o ~/.config/chezmoi/key.txt
Public key: age1uank7kslpm8kerxu2ndalukp7mceu7l4mqmugfls5k5th8fzyp6qmlaa9k
# chezmoi.toml — encryption MUST be top-level, ABOVE any [section]
encryption = "age"
[age]
identity = "~/.config/chezmoi/key.txt"
recipient = "age1uank7kslpm8kerxu2ndalukp7mceu7l4mqmugfls5k5th8fzyp6qmlaa9k"
chezmoi add --encrypt ~/.secret-key
ls ~/.local/share/chezmoi
encrypted_dot_secret-key.age
head -1 ~/.local/share/chezmoi/encrypted_dot_secret-key.age
-----BEGIN AGE ENCRYPTED FILE-----
chezmoi cat ~/.secret-key # decrypts on demand, on the fly
1 · TOML ordering: appending encryption = "age" below an existing [keepassxc] section silently makes it keepassxc.encryption. chezmoi then warns 'encryption' not set, using age configuration. Check if 'encryption' is correctly set as the top-level key and fails with no recipients specified. Top-level keys go above the first [section] — always.
2 · The [age] recipient accepts native age1… keys only. An ssh-ed25519 public key is rejected with malformed recipient … mixed case, even though standalone age supports ssh recipients. Generate a real age key.
The age identity file is now your recovery keystone: without it, encrypted files in the repo are noise. Store a copy of key.txt in KeePassXC (as an attachment) — the vault guards the key, the key guards the files.
--encrypt does not imply private_Verified: a file added with --encrypt came back mode 644 after apply. Encryption protects the repo copy; permissions protect the rendered copy. For key material, you want encrypted_private_ — run chezmoi chattr private on it after adding.Sources: KeePassXC integration · age encryption · keepassxc template functions
11 · External dependencies: stop vendoring other people's code
Right now your source repo contains a full copy of oh-my-zsh as tracked files. That is vendoring: upstream updates never arrive, and hundreds of files that aren't yours pollute every diff. .chezmoiexternal declares "fetch this from there" instead.
# .chezmoiexternal.toml (in the source root)
[".oh-my-zsh"]
type = "archive"
url = "https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz"
exact = true
stripComponents = 1
refreshPeriod = "168h" # re-check upstream weekly
Three types cover practice: file (single file), archive (unpacked tarball/zip), git-repo (cloned/pulled). refreshPeriod controls how often chezmoi re-downloads; between refreshes, applies use the cache and work offline. exact = true keeps the unpacked tree pristine.
Verified in the sandbox with a single-file external:
[".local/share/fzf-license"]
type = "file"
url = "https://raw.githubusercontent.com/junegunn/fzf/master/LICENSE"
refreshPeriod = "168h"
# → chezmoi apply downloaded it; `chezmoi managed` now lists it
run_once_ script git clone oh-my-zsh? Yes — but then chezmoi doesn't manage the result: no managed listing, no exact cleanup, no refresh policy. Prefer externals for content, scripts for actions.Sources: include files from elsewhere · .chezmoiexternal reference
12 · Capstone: finish your real adoption
Everything above was rehearsal. This is the performance: take your stalled real setup — dot_zshrc, dot_zshrc.d, dot_gitconfig, dot_gitignore_global, and a vendored dot_oh-my-zsh — to a complete, published, secrets-aware, second-machine-ready system. Work top to bottom; each phase ends with a verifiable checkpoint.
chezmoi diff and read it. chezmoi never touches a file apply didn't list, and the clobber guard (§6) asks before overwriting drifted files. First move below is a git commit — your rollback point for the source; your live dotfiles are only changed by applies you previewed.Baseline and commit
chezmoi doctor — all rows ok. chezmoi cd, git add -A && git commit -m "baseline before completing adoption". Then chezmoi status: any M rows are drift between your source and reality. Resolve each deliberately — re-add if the live file is right, apply if the source is right.
Complete the inventory
Adopt what's missing. Candidates worth checking: chezmoi add ~/.zprofile ~/.config/git ~/.ssh/config (config only — never keys unencrypted), ~/.config/lazygit, fzf setup, anything you'd hate to reconstruct. Mark sensitive ones: chezmoi chattr private ~/.ssh/config. Checkpoint: chezmoi managed reads like a complete list of "my setup".
Replace vendored oh-my-zsh with an external
chezmoi forget ~/.oh-my-zsh (source entries removed; live directory untouched), but keep your customizations: chezmoi add ~/.oh-my-zsh/custom selectively, or better, move custom bits into ~/.zshrc.d. Then declare the external as in §11 and chezmoi apply. Checkpoint: repo diff shrinks by hundreds of files; a stray .DS_Store in custom/ stops being "managed". Add .DS_Store to .chezmoiignore while you're there.
Templatize for machine two
Convert dot_gitconfig → dot_gitconfig.tmpl (chezmoi chattr +template): email from {{ .email }}, editor per OS. Write .chezmoi.toml.tmpl with promptStringOnce for email and promptBool for work-machine. Checkpoint: chezmoi cat ~/.gitconfig renders correctly; chezmoi init regenerates your config without changing answers.
Wire KeePassXC
Add the [keepassxc] stanza pointing at your real vault. Move one real secret (a token in some rc file today) into the vault, replace it with a {{ (keepassxc "…").Password }} reference in a private_*.tmpl, apply, verify with grep that the plaintext exists nowhere in the source. Checkpoint: one prompt per apply; grep -r for the secret in $(chezmoi source-path) comes back empty.
Publish
Create a private GitHub repo dotfiles. chezmoi cd; git remote add origin git@github.com:you/dotfiles.git; git push -u origin main. Checkpoint: repo browsable, zero secrets in it (search the repo for a known token fragment to prove it).
Bootstrap "machine two" — without owning one
Dress rehearsal via the sandbox flags, against the real published repo: chezmoi --source $(mktemp -d) --destination $(mktemp -d) --config $(mktemp -d)/chezmoi.toml init --apply git@github.com:you/dotfiles.git. Watch it prompt (your .chezmoi.toml.tmpl), fetch the external, run run_once_ scripts, render templates. Checkpoint: the throwaway "home" is a faithful clone of your setup. When a real second machine arrives, it's the one-liner from §8.
chezmoi status clean · managed complete · oh-my-zsh external · gitconfig templated · one secret via KeePassXC · repo private on GitHub · sandbox bootstrap succeeds end-to-end. That is full adoption — the stalled phase is over.13 · Troubleshooting
Every symptom below was either reproduced while building this guide or is a documented failure mode. Start with chezmoi doctor; it catches config, git, and tool problems in one table.
| Symptom | Cause | Fix |
|---|---|---|
chezmoi: .config: inconsistent state (…dot_config, …private_dot_config) | Two source entries map to the same target | Keep one source entry; express attributes on it alone. Delete or merge the other. |
'encryption' not set, using age configuration… then no recipients specified | encryption = "age" placed below a [section] in TOML — it became that section's key | Move it above the first section header (top level) |
malformed recipient "ssh-ed25519 …": mixed case | ssh public key given as age recipient; chezmoi's [age] config takes native keys only | brew install age, age-keygen -o key.txt, use the age1… key |
could not open a new TTY during apply/template | A prompt (KeePassXC unlock, clobber confirm) with no terminal attached | Run interactively; or key-file db + prompt = false; or apply --force if it's the overwrite confirm and you mean it |
| Apply stops asking about a file you edited or deleted by hand | Clobber guard: destination changed since chezmoi last wrote it | chezmoi re-add to keep the edit, apply --force to discard it |
config file template has changed, run chezmoi init to regenerate | .chezmoi.toml.tmpl in the repo is newer than your generated config | Run chezmoi init (answers survive with promptStringOnce) |
Template error map has no entry for key "email" | Template references data this machine's config doesn't define | Add it to [data], or guard with {{ if hasKey . "email" }}, or move the prompt into .chezmoi.toml.tmpl |
run_once_ script never re-runs after you fixed it | "Once" is keyed on contents hash in the persistent state — the failed run recorded success? No: only successful runs record. If it ran successfully once, same contents won't re-run | Change the script contents, or chezmoi state delete-bucket --bucket=scriptState to reset all once-state |
| Ignored file still appears | .chezmoiignore logic inverted — patterns name what to ignore, conditions choose when | chezmoi execute-template < .chezmoiignore to see the rendered pattern list for this machine |
| Secret rendered world-readable | --encrypt or .tmpl without private_ | chezmoi chattr private <target>; verify with ls -l |
Edits to ~/.zshrc keep vanishing | You edit the destination; apply rewrites from source | Retrain the hand: chezmoi edit --apply ~/.zshrc; recover past edits with re-add before the next apply |
14 · Cheat sheet
Daily loop
chezmoi add ~/.zshrc
chezmoi edit --apply ~/.zshrc
chezmoi diff
chezmoi apply -v
chezmoi status
chezmoi re-add
chezmoi cdSync
chezmoi update # pull + apply
chezmoi git pull -- --autostash --rebase
chezmoi diff # then apply
chezmoi init --apply $GITHUB_USERNAME
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply youInspect
chezmoi managed
chezmoi source-path ~/.zshrc
chezmoi cat ~/.gitconfig
chezmoi data --format json
chezmoi execute-template '{{ .chezmoi.os }}'
chezmoi doctor
chezmoi state dumpAttributes (source filename)
dot_ → leading .private_ → 600executable_ → +xsymlink_create_ oncemodify_ scriptexact_ dirsencrypted_.tmpl render
chezmoi chattr private,+template ~/.gitconfigUniversal starting pattern
# bring a machine from zero to yours
sh -c "$(curl -fsLS get.chezmoi.io)" \
-- init --apply $GITHUB_USERNAME
Everything this guide builds funnels into making that one line safe to run anywhere.
Secrets
{{ (keepassxc "entry").Password }}
{{ keepassxcAttribute "entry" "attr" }}
chezmoi add --encrypt ~/.secret-key
age-keygen -o key.txt
encryption = "age" top-level; private_ is separate from encryption.
Glossary
- source state
- The declared truth: the contents of the source directory, attribute-encoded filenames and all. What you version and share. (§1)
- source directory
~/.local/share/chezmoi— a normal git repository holding the source state.chezmoi cddrops you into it. (§5)- target state
- The computed desired result: templates rendered with data, attributes resolved to permissions and types. Exists in memory during a run. (§1)
- destination state
- What is actually in the destination directory (usually
~) right now.applyconverges it toward the target state. (§1) - attribute
- A filename prefix/suffix in the source state (
private_,dot_,.tmpl…) declaring metadata git can't store natively. (§6) - config file
~/.config/chezmoi/chezmoi.toml— per-machine, never in the repo. Holds[data], secrets backends, git automation, encryption settings. (§7)- .chezmoi.toml.tmpl
- Special repo file rendered by
chezmoi initto generate the config file, typically via prompt functions. The bridge between shared repo and per-machine data. (§8) - template data
- The merged dictionary visible to templates: built-in
.chezmoi.*values + config[data]+.chezmoidata.*+ secret functions. Inspect withchezmoi data. (§7) - external
- Content fetched from a URL at apply time (
file,archive,git-repo) declared in.chezmoiexternal.toml, cached perrefreshPeriod. (§11) - persistent state
- A small boltdb (not the git repo) where chezmoi records script hashes and last-written file states — the memory behind
run_once_and the clobber guard.chezmoi state dumpshows it. (§9) - clobber guard
- Apply-time confirmation when a managed destination file changed out-of-band since chezmoi last wrote it. Resolved by
re-add(keep) or--force(discard). (§6) - script (run_)
- A source-state entry executed rather than written:
run_once_(per unique contents),run_onchange_(when rendered contents change), withbefore_/after_ordering around the file pass. (§9) - adoption
- Bringing existing real files under management with
chezmoi add— as opposed to authoring files in the source first. Your current, half-finished journey. (§5, §12)
Index
Commands, files, flags, and concepts → the section that teaches them.
- add · add --encrypt
- .chezmoi.os / .arch / .hostname
- apply · apply --force
- application order
- age · age-keygen
- autoCommit · autoPush
- cat (render preview)
- cd (source subshell)
- chattr
- .chezmoidata
- .chezmoiexternal
- .chezmoiignore
- .chezmoi.toml.tmpl
- .chezmoitemplates
- clobber guard
- config file · [data]
- create_
- edit · edit --apply · edit --watch
- encrypted_ · encryption =
- empty_ · exact_ · executable_
- execute-template
- externals: file / archive / git-repo
- oh-my-zsh as external
- private_
- promptString · promptBool · promptStringOnce
- re-add
- readonly_ · remove_
- refreshPeriod
- run_once_ · run_onchange_ · run_before_ · run_after_
- sandbox (--source/--destination)
- sha256sum hash trick
- source-path
- state · scriptState · state delete-bucket
- status ( M vs MM)
- symlink_
Retrieval quiz
Flashcard style: answer out loud first, then reveal. Aim for 12/14 before calling the guide done; anything marked Review, reread its section the next day.
chezmoi diff compares.~). diff compares target vs destination. (§1)private_; the cost is that edits aren't live until apply. (§1)private_executable_dot_local/bin/deploy.tmpl produce, with what properties?~/.local/bin/deploy… careful — dot_local is the directory attribute; the file itself renders as a template, gets mode 700 (private strips group/world, executable adds +x). (§6)~/.zshrc directly (old habit). What does status show, and what are your two exits?create_ file whose target you've since edited?create_ means "ensure exists with these contents if absent" — it never overwrites, and status stays clean. Verified in the sandbox. (§6)dot_config and private_dot_config exist in your source. What happens at apply?chezmoi: .config: inconsistent state — two source entries map to one target. One target = one source entry carrying all its attributes. (§6)[data] changes?cat/execute-template) time, never at shell startup. Rendered files are plain text; a data change does nothing until the next apply. (§7).chezmoiignore logic called "inverted", and how do you debug it?chezmoi update run?git pull --autostash --rebase in the source directory, then chezmoi apply. The cautious alternative: chezmoi git pull, chezmoi diff, then apply. (§8).chezmoi.toml.tmpl special, and which prompt function avoids re-asking on re-init?chezmoi init renders it to generate the machine's config file — it's how one shared repo produces per-machine data. promptStringOnce reuses previous answers. (§8)run_onchange_ and embed the other file's hash in a comment: {{ include "dot_gitconfig.tmpl" | sha256sum }}. Contents change → hash changes → script fires. Verified live. (§9)could not open a new TTY unless the db is key-file protected with prompt = false.) (§10)--encrypt protects only the repo copy and does not imply private_. Fix: chezmoi chattr private so the source entry is encrypted_private_…. (§10).chezmoiexternal better than keeping oh-my-zsh as tracked files or cloning it in a run_once_ script?managed, no exact cleanup, no refresh). An external is managed content with an update policy (refreshPeriod), cached for offline applies. (§11)Sources
Checked against chezmoi v2.72.0 (Homebrew) on macOS, 2026-08-06, with keepassxc-cli 2.7.12 and age v1.3.1. Every transcript in this guide is real output from a sandboxed run of those versions; drift notes record where behavior surprised. External links need network; the guide itself works offline.
- Quick start — the official 10-minute loop; best as a warm-up before §5.
- Setup — publishing to GitHub and bootstrapping new machines; pairs with §8.
- Daily operations — edit/update/autoCommit reference; pairs with §5 and §8.
- Machine-to-machine differences — the templating cookbook behind §7.
- Source state attributes — the complete attribute table; the authority behind §6.
- Application order — exact apply sequencing; behind §9.
- KeePassXC integration — config keys and template functions; behind §10.
- age encryption — full encryption setup including passphrase mode; behind §10.
- Include files from elsewhere — externals cookbook (oh-my-zsh is its lead example); behind §11.
- Design FAQ — why no symlinks, one source of truth; behind §1.
- Releases — v2.72.0 (2026-08-02) confirmed latest at check date; trust the changelog over this page as versions move.
chezmoi helpandman chezmoi— exact flag semantics for your installed binary.
Compiled 2026-08-06 by Claude Code for Paul. Facts verified against the sources and live sandbox runs on that date.
Printed from the local chezmoi field guide.