Dotfiles, Part 2: Managing Cross-Machine Config and Secrets with Chezmoi
Moving from Dotbot to Chezmoi — layered management of shared config, machine-local overrides, and secrets.
In the previous post, I covered how to build a lightweight dotfiles setup with Dotbot and Brewfile. The core idea: keep config files in a Git repo and symlink them to where the system expects them. That solves 80% of the problem — clone, install, and you’re back to a familiar environment.
But after a while, I hit some new problems:
- Some apps modify their own config files, leaving the dotfiles repo constantly dirty.
- Config files mix API keys, local paths, email accounts, and SSH hosts.
.localfiles help, but not every app supports include or source directives. - Symlinks make config intuitive, but they also mean “app writes to config” equals “app writes directly to your Git repo.”
So I migrated from Dotbot to Chezmoi.
This is Part 2 — the next level of dotfiles: layered management of shared config, machine-local overrides, and secrets.
Dotbot vs Chezmoi: The Core Difference
Dotbot works with symlinks:
~/.zshrc → ~/.dotfiles/shell/.zshrc~/.config/zed → ~/.dotfiles/config/zed~/.config/opencode → ~/.dotfiles/config/opencodeApps read standard paths like ~/.zshrc, but the real file lives in the dotfiles repo.
The consequence: when an app writes to its config, it writes directly to a file in your Git repo. This is a feature (changes show up as diffs instantly) and a bug (tokens, caches, recently opened files, window positions, auto-generated state files can all sneak in).
Chezmoi defaults to a source/apply model instead:
~/.local/share/chezmoi/dot_zshrc.tmpl --apply--> ~/.zshrc~/.local/share/chezmoi/dot_config/zed/... --apply--> ~/.config/zed/...~/.local/share/chezmoi/private_dot_ssh/config --apply--> ~/.ssh/configThe repo stores source files or templates. chezmoi apply generates the actual files in $HOME at your command. When a third-party app writes to ~/.config/zed/settings.json, it only modifies the real file in $HOME — the source repo stays clean. If you want to capture that change, you explicitly run:
chezmoi re-add ~/.config/zed/settings.jsonWhy Migrate from Dotbot?
Dotbot is great for minimal setups: few config files, clear structure, not much machine-to-machine variance. Symlinks are simple and reliable.
Chezmoi pulls ahead when:
- Multiple machines share config but have different users, paths, and keys.
- Some apps don’t support
.localfiles, but their config must contain machine-private values. - You don’t want API keys in Git — or even in plaintext on disk long-term.
- You want to use a password manager (Bitwarden CLI, etc.) to manage secrets.
Apps still see ordinary config files. But those configs are generated by Chezmoi at apply time, not the repo files themselves.
Setting Up a Chezmoi Repo
Chezmoi can adopt files already in your $HOME:
brew install chezmoichezmoi initchezmoi add ~/.zshrcchezmoi add ~/.gitconfigchezmoi add ~/.config/zed/settings.jsonThe default source directory is ~/.local/share/chezmoi, which is itself a Git repo.
chezmoi cdgit add -Agit commit -m "Initial dotfiles"git push -u origin mainChezmoi has a naming convention. Common examples:
dot_zshrc → ~/.zshrcdot_gitconfig → ~/.gitconfigdot_config/zed/settings.json → ~/.config/zed/settings.jsonprivate_dot_ssh/config → ~/.ssh/config (with strict permissions)Add .tmpl for template support:
dot_zshrc.tmpl → ~/.zshrcdot_gitconfig.tmpl → ~/.gitconfigdot_config/opencode/config.tmpl → ~/.config/opencode/configSolving What .local Files Can’t
In the previous post, I used .local files for machine-private config:
[ -f ~/.zshrc.local ] && source ~/.zshrc.localThis works great for shell, SSH, and tmux — tools that support include/source. But many apps don’t. A GUI app reads:
~/.config/app/settings.jsonIt won’t load:
~/.config/app/settings.local.jsonThis is where Chezmoi templates shine. Instead of making the app support .local, you compose the config before the app ever reads it.
In the repo:
dot_config/app/settings.json.tmplTemplate content:
{ "theme": "dark", "email": {{ .email | quote }}, "apiKey": {{ .app_api_key | quote }}}Machine data goes in ~/.config/chezmoi/chezmoi.toml:
[data]email = "[email protected]"app_api_key = "..."Run:
chezmoi applyThe app reads plain JSON:
{ "theme": "dark", "apiKey": "..."}The app has no idea templates or .local files exist.
Machine Data: chezmoi.toml
Chezmoi lets each machine have its own local config. Mine lives at:
~/.config/chezmoi/chezmoi.tomlIt’s not in the dotfiles repo, with permissions set to 600:
chmod 600 ~/.config/chezmoi/chezmoi.tomlUse it for non-sensitive machine differences and references to password manager items:
[data]machine_profile = "personal-mac"git_user_name = "Davy"git_user_email = "[email protected]"git_signing_key = "~/.ssh/id_ed25519.pub"
use_bitwarden_secrets = trueopencode_api_key_minimax_bw_item = "chezmoi/opencode/minimax-api-key"Real API keys never live here. chezmoi.toml only stores “which Bitwarden item to fetch.”
Managing Real Secrets with Bitwarden
Chezmoi can call a password manager through its templates. For Bitwarden CLI:
"apiKey": {{ (bitwarden "item" .opencode_api_key_minimax_bw_item).login.password | quote }}Before running chezmoi apply, unlock the Bitwarden CLI:
export BW_SESSION="$(bw unlock --raw)"chezmoi applyResult: no API key in Git, no API key in chezmoi.toml. The secret lives in Bitwarden.
A caveat: if the target app requires a plaintext key in its config file, the generated file will still contain the plaintext key. The password manager solves “don’t put secrets in Git” — it doesn’t eliminate plaintext on disk entirely. Keep generated config files protected and out of backups.
Real Migration: OpenCode
OpenCode’s config has multiple providers, each needing an API key.
With Dotbot, the entire ~/.config/opencode was a symlink — any GUI or CLI write polluted the repo.
After migrating to Chezmoi, I renamed:
dot_config/opencode/opencode.jsonto:
dot_config/opencode/opencode.json.tmplAnd changed the keys from plaintext to:
{ "provider": { "minimax": { "options": { "apiKey": {{ (bitwarden "item" .opencode_api_key_minimax_bw_item).login.password | quote }} } } }}chezmoi.toml only stores:
[data]opencode_api_key_minimax_bw_item = "chezmoi/opencode/minimax-api-key"The item name is safe to sync. The real key never is.
SSH Keys: To Store in a Password Manager or Not?
Technically yes — Chezmoi can fetch SSH private keys from Bitwarden and generate ~/.ssh/id_ed25519. But I don’t recommend it by default.
A better approach: generate a unique SSH key per machine:
ssh-keygen -t ed25519 -C "your-email"Add each machine’s public key to GitHub or your servers. If one machine is compromised, you revoke only that key — no need to rotate a shared key across every device.
I split SSH host config into two categories:
- Public host aliases — safe in Chezmoi templates.
- Private hosts, internal IPs, special key paths — store in Bitwarden or local data.
Common Chezmoi Commands
See what would change:
chezmoi diffEnter the source repo:
chezmoi cdgit diffDry-run:
chezmoi apply --dry-run --verboseApply for real:
chezmoi apply --verboseCapture a config change back to the repo (Chezmoi doesn’t auto-write back):
chezmoi re-add ~/.config/zed/settings.jsonThen check the diff:
chezmoi cdgit diffConfirm no tokens, caches, or machine paths leaked in, then commit.
Recovery on a New Machine
From a blank Mac:
# 1. Install chezmoi and bitwarden-cli (after Homebrew)brew install chezmoi bitwarden-cli
# 2. Clone dotfiles to ~/.local/share/chezmoi
# 3. Prepare machine-local chezmoi.tomlmkdir -p ~/.config/chezmoivim ~/.config/chezmoi/chezmoi.toml
# 4. Login and unlock Bitwardenbw loginexport BW_SESSION="$(bw unlock --raw)"
# 5. Dry-run firstchezmoi apply --dry-run --verbose
# 6. Applychezmoi apply --verboseIgnore Rules: Both .gitignore and .chezmoiignore
.gitignore prevents files from entering Git but doesn’t stop Chezmoi from applying them.
.chezmoiignore prevents files from being materialized to $HOME.
My base rules (in both files):
# local overrides*.local*.local.**.bak*.backup
# secrets / auth*token**secret**credential**credentials**client_secret**license*hosts.yml
# package/cache artifactsnode_modules/package-lock.jsonbun.lockbun.lockb.pnpm-store/
# local chezmoi config.chezmoi*.toml.chezmoi*.yamlNotes
Don’t let Chezmoi manage login state files.
~/.config/gh/hosts.yml is a GitHub CLI login session — generate it per-machine with gh auth login.
Don’t sync SSH private keys by default.
Password managers can store them, but per-machine independent keys are more secure.
Results
After migrating to Chezmoi, dotfiles evolved from “back up a set of files” to “generate a machine’s configuration.”
- Shared config in Git: Shell, Git, editor, and CLI tool configs are versioned.
- Machine differences stay local: Paths, usernames, and machine roles never pollute the repo.
- Secrets in a password manager: API keys aren’t in Git and don’t linger in chezmoi.toml.
- App writes no longer dirty the repo: Explicit
re-addrequired, reducing accidental commits. - Controlled migration: Dry-run shows exactly which symlinks will be replaced and which files generated.
Dotbot is a great starting point — lightweight, intuitive, enough for most config sync needs. Chezmoi is more complex but much better suited for multi-machine setups and finer-grained control.