↑ Top
Dotfiles manager · user guide + hands-on labs

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.

Checked with chezmoi v2.72.0 · 2026-08-06 chezmoi v2.72.1 now available · not yet re-verified macOS (Homebrew) · applies to Linux Single file · works offline Labs + simulator · KeePassXC 2.7.12 & age 1.3.1 verified

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/chezmoi as 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.
chezmoi's three states: the source directory (a git repo) plus machine data feed the computed target state; apply writes the target state into the home directory; diff and status compare target with destination; add and re-add pull files from the home directory back into the source directory. source state ~/.local/share/chezmoi (git) dot_zshrc dot_gitconfig.tmpl private_dot_netrc run_once_setup.sh data config [data] · .chezmoidata · secrets target state templates rendered, attributes resolved · in memory destination ~ (your real files) .zshrc .gitconfig .netrc (600) (scripts already ran) render apply add · re-add (pull real files into source) diff · status
Every command is a movement between states: 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.

Key idea: real files, not symlinkschezmoi writes ordinary files into ~. 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:

ApproachMechanismPer-machine differencesSecretsWhere it breaks
chezmoiGenerate real files from versioned sourceTemplates, per-machine data, .chezmoiignorePassword-manager functions, age/gpg encryptionExtra concept load: attributes, templates, two-step edit→apply
Bare git repo in $HOMETrack files in placeBranches per machine (merge pain grows forever)None — plaintext or manual hacksOne accidental git clean away from disaster; no metadata
GNU StowSymlink farm from a packages dirSeparate packages per machine, manualNoneSymlinks break tools that resolve paths; no templating; no perms control
yadmGit wrapper + alternates + templatesAlternate files per OS/hostBuilt-in encryptionStill 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 status vs git diff shows? (If not: any git primer before §8.)
  • Do you know why ~/.netrc should be mode 600 while ~/.zshrc need not be?
  • Can you predict what ls -l shows 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)"
Lab 1 · Build the sandbox you will reuse all guide
5 min · local only · touches nothing real

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
  • init with no argument creates an empty source repo. (With a repo URL it clones instead — §8.)
  • Run $LAB/cz doctor. Expect a table of ok rows: version, latest-version, config-file, source-dir "is a git working tree (clean)". doctor is your first stop whenever anything misbehaves.
  • Nothing outside $LAB changed. Verify with chezmoi source-path (no flags) — it still points at your real ~/.local/share/chezmoi.
Why sandbox first when you already have a real source dir?You are mid-adoption: your real source directory already tracks 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.

chezmoi · state-flow practicev1 = version counters, not file contents

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: MM would 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 instinctchezmoi moveWhy it is better
vim ~/.zshrcchezmoi edit --apply ~/.zshrcEdits the source copy, applies on quit. The change is captured for every machine, not just this one.
cp .zshrc backups/chezmoi add ~/.zshrc + git commitVersioned history with diffs, not timestamped copies.
"what did I change?"chezmoi diff / chezmoi statusCompares desired vs actual state, including permissions — not just contents.
scp configs to new machinechezmoi init --apply $GITHUB_USERNAMEOne command reproduces the whole home directory, templates resolved for that machine.
edited the real file directly (habit)chezmoi re-addPulls your hand edit back into the source instead of losing it at next apply.
Lab 2 · Run the add → edit → diff → apply loop
8 min · sandbox from Lab 1
# 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"
  • status after apply prints nothing — clean means converged.
  • Now edit the destination by hand: echo 'alias gs="git status"' >> $LAB/home/.zshrc, then run status. Expect MM .zshrc once the source has also changed, or a single right-column M when only the real file moved.
  • Run $LAB/cz re-add. Status is clean again and grep gs $LAB/src/dot_zshrc finds 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 destroy deletes both — almost never what you want.
Misconception: "chezmoi add copies my file into a config database"It copies the file into a plain git repo with an encoded filename, nothing more. 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.

Anatomy of the source filename private_dot_config/private_gh-token.tmpl: the private prefix removes group and world permissions, dot becomes a leading dot, and the .tmpl suffix renders the contents as a template, producing ~/.config/gh-token with mode 600. private_ executable_ dot_ local/bin/deploy .tmpl strip group+world perms add +x leading “.” render as template → ~/.local/bin/deploy mode 700, rendered
Attributes compose left to right and vanish from the target name. The same grammar covers directories.
PrefixDeclaresVerified behavior (v2.72.0)
dot_Target name starts with .dot_zshrc.zshrc
private_Strip group/world permissionsprivate_dot_netrc applied as -rw------- (600) — seen in the apply diff as new file mode 100600
executable_Add execute bitApplied as -rwxr-xr-x
readonly_Strip write permissionsCombine: private_readonly_ → 400
symlink_Target is a symlink; file contents are the link destinationsymlink_dot_theme.conf.tmpl containing a path template produced .theme.conf → /Users/you/…
create_Create with these contents only if absent; never overwrite afterHand-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 emptyZero-byte files are otherwise removed from the target state
exact_ (dirs)Delete anything in the directory chezmoi does not manageUse for config dirs that must contain nothing stray
remove_Remove the target if presentDeclarative deletion
encrypted_Contents are encrypted in the source (§10)encrypted_dot_secret-key.age
literal_ / .literalStop attribute parsingFor 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
Lab 3 · Watch attributes become permissions
7 min · sandbox
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_hello script under $LAB/src/dot_local/bin/ and apply; expect -rwxr-xr-x on ~/.local/bin/hello.
  • Try creating both dot_config and private_dot_config directories 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.
The clobber guardIf a managed file changes (or disappears) in the destination out-of-band, the next 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 functionskeepassxc and 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 }}
Lab 4 · Render, inspect, and conditionally ignore
10 min · sandbox
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 cat and execute-template are your template debuggers. Use them before apply, every time you write a template.
  • Change editor's condition to eq .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/false flag in each machine's config [data] over hostname string-matching; hostnames change, roles don't.
Misconception: templates run when the shell reads the fileTemplates render at 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.

Multi-machine topology: a dotfiles repository on GitHub in the center; machine A pushes changes with chezmoi cd plus git push; machine B bootstraps with chezmoi init --apply and stays current with chezmoi update, which pulls and applies; each machine keeps its own config file with local data that never leaves the machine. github.com/you/dotfiles the only thing machines share machine A (Mac) ~/.local/share/chezmoi + ~ config: [data] work = false (local only, never pushed) machine B (Linux) bootstrap: init --apply you config generated by .chezmoi.toml.tmpl prompts chezmoi cd; git push chezmoi update update
Machines share only the repo. Per-machine identity lives in each machine's config file, generated at 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 }}
Prefer 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.
Lab 5 · Two machines on one laptop
15 min · sandbox + a bare repo as a stand-in for GitHub

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/.zshrc shows 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, then chezmoi diff, then apply.

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 patternRunsUse for
run_once_*.shOnce per unique contents, ever (per machine)Package installs, one-time setup
run_onchange_*.shWhenever its rendered contents change"Reload X when its config changes"
run_*.shEvery applyRare — keep idempotent and fast
run_before_* / run_after_*Before / after files are updatedOrdering 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.

Lab 6 · once vs onchange, proven
8 min · sandbox
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 sha256sum comment 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 under scriptState in 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 -}}
Scripts run with your privileges, from your repoAnything you merge into the dotfiles repo executes on every machine at next apply. Review script diffs before 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.

Prompt and cache behavior, verifiedchezmoi prompts for the database password on the terminal (/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.
Lab 7 · A throwaway vault, end to end
12 min · sandbox · throwaway .kdbx, key-file protected so it never prompts
# 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/src finds 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
Two footguns, both hit live while testing this guide

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
Externals vs scriptsCould a 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.

Safety rails for the real thingBefore each phase run 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.
0

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.

1

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

2

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.

3

Templatize for machine two

Convert dot_gitconfigdot_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.

4

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.

5

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

6

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.

Done meanschezmoi 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.

SymptomCauseFix
chezmoi: .config: inconsistent state (…dot_config, …private_dot_config)Two source entries map to the same targetKeep one source entry; express attributes on it alone. Delete or merge the other.
'encryption' not set, using age configuration… then no recipients specifiedencryption = "age" placed below a [section] in TOML — it became that section's keyMove it above the first section header (top level)
malformed recipient "ssh-ed25519 …": mixed casessh public key given as age recipient; chezmoi's [age] config takes native keys onlybrew install age, age-keygen -o key.txt, use the age1… key
could not open a new TTY during apply/templateA prompt (KeePassXC unlock, clobber confirm) with no terminal attachedRun 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 handClobber guard: destination changed since chezmoi last wrote itchezmoi 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 configRun chezmoi init (answers survive with promptStringOnce)
Template error map has no entry for key "email"Template references data this machine's config doesn't defineAdd 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-runChange 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 whenchezmoi 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 vanishingYou edit the destination; apply rewrites from sourceRetrain 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 cd

Sync

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 you

Inspect

chezmoi managed
chezmoi source-path ~/.zshrc
chezmoi cat ~/.gitconfig
chezmoi data --format json
chezmoi execute-template '{{ .chezmoi.os }}'
chezmoi doctor
chezmoi state dump

Attributes (source filename)

dot_ → leading .private_ → 600executable_ → +xsymlink_create_ oncemodify_ scriptexact_ dirsencrypted_.tmpl render

chezmoi chattr private,+template ~/.gitconfig

Universal 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 cd drops 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. apply converges 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 init to 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 with chezmoi data. (§7)
external
Content fetched from a URL at apply time (file, archive, git-repo) declared in .chezmoiexternal.toml, cached per refreshPeriod. (§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 dump shows 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), with before_/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.

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.

Score: 0 learned · 0 review · 0/14 answered
Q1Name the three states, and which two of them chezmoi diff compares.
Source state (declared, in the repo), target state (computed desired result), destination state (what's really in ~). diff compares target vs destination. (§1)
Q2Why does chezmoi write real files instead of symlinking into the repo?
A symlink can't be a rendered template, an encrypted file, or carry different permissions than the repo copy. Real files enable templates, encryption, and private_; the cost is that edits aren't live until apply. (§1)
Q3What target does 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)
Q4You edited ~/.zshrc directly (old habit). What does status show, and what are your two exits?
A right-column M (destination differs from target; MM once the source has moved too). Exits: chezmoi re-add to capture your edit into the source, or chezmoi apply (answering/forcing the clobber guard) to discard it. (§5, §6)
Q5What happens the second time you apply a create_ file whose target you've since edited?
Nothing. create_ means "ensure exists with these contents if absent" — it never overwrites, and status stays clean. Verified in the sandbox. (§6)
Q6Both dot_config and private_dot_config exist in your source. What happens at apply?
Hard error: chezmoi: .config: inconsistent state — two source entries map to one target. One target = one source entry carrying all its attributes. (§6)
Q7When do templates render — and what does that mean when [data] changes?
At apply (or cat/execute-template) time, never at shell startup. Rendered files are plain text; a data change does nothing until the next apply. (§7)
Q8Why is .chezmoiignore logic called "inverted", and how do you debug it?
Patterns name what to ignore, so conditions usually read "ignore unless this machine matches" (if ne .chezmoi.os "linux"). Debug by rendering it: chezmoi execute-template < .chezmoiignore. (§7, §13)
Q9What exactly does 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)
Q10What makes .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)
Q11How do you make a script re-run when another file changes?
Name it 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)
Q12How many KeePassXC password prompts does one apply with five secret references cost, and why?
One. chezmoi prompts on the TTY once and caches the unlock for the duration of that run. (Headless runs fail with could not open a new TTY unless the db is key-file protected with prompt = false.) (§10)
Q13Your encrypted SSH key applied as mode 644. What went wrong?
Nothing "went wrong" — --encrypt protects only the repo copy and does not imply private_. Fix: chezmoi chattr private so the source entry is encrypted_private_…. (§10)
Q14Why is .chezmoiexternal better than keeping oh-my-zsh as tracked files or cloning it in a run_once_ script?
Tracked files = frozen vendored copy polluting every diff; a script clone isn't managed at all (no 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 help and man 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.