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. .local files 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/opencode

Apps 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/config

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

Terminal window
chezmoi re-add ~/.config/zed/settings.json

Why 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 .local files, 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:

Terminal window
brew install chezmoi
chezmoi init
chezmoi add ~/.zshrc
chezmoi add ~/.gitconfig
chezmoi add ~/.config/zed/settings.json

The default source directory is ~/.local/share/chezmoi, which is itself a Git repo.

Terminal window
chezmoi cd
git remote add origin [email protected]:yourname/dotfiles.git
git add -A
git commit -m "Initial dotfiles"
git push -u origin main

Chezmoi has a naming convention. Common examples:

dot_zshrc → ~/.zshrc
dot_gitconfig → ~/.gitconfig
dot_config/zed/settings.json → ~/.config/zed/settings.json
private_dot_ssh/config → ~/.ssh/config (with strict permissions)

Add .tmpl for template support:

dot_zshrc.tmpl → ~/.zshrc
dot_gitconfig.tmpl → ~/.gitconfig
dot_config/opencode/config.tmpl → ~/.config/opencode/config

Solving What .local Files Can’t

In the previous post, I used .local files for machine-private config:

~/.zshrc
[ -f ~/.zshrc.local ] && source ~/.zshrc.local

This works great for shell, SSH, and tmux — tools that support include/source. But many apps don’t. A GUI app reads:

~/.config/app/settings.json

It won’t load:

~/.config/app/settings.local.json

This 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.tmpl

Template content:

{
"theme": "dark",
"email": {{ .email | quote }},
"apiKey": {{ .app_api_key | quote }}
}

Machine data goes in ~/.config/chezmoi/chezmoi.toml:

[data]
app_api_key = "..."

Run:

Terminal window
chezmoi apply

The app reads plain JSON:

{
"theme": "dark",
"email": "[email protected]",
"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.toml

It’s not in the dotfiles repo, with permissions set to 600:

Terminal window
chmod 600 ~/.config/chezmoi/chezmoi.toml

Use 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 = true
opencode_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:

Terminal window
export BW_SESSION="$(bw unlock --raw)"
chezmoi apply

Result: 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.json

to:

dot_config/opencode/opencode.json.tmpl

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

Terminal window
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:

Terminal window
chezmoi diff

Enter the source repo:

Terminal window
chezmoi cd
git diff

Dry-run:

Terminal window
chezmoi apply --dry-run --verbose

Apply for real:

Terminal window
chezmoi apply --verbose

Capture a config change back to the repo (Chezmoi doesn’t auto-write back):

Terminal window
chezmoi re-add ~/.config/zed/settings.json

Then check the diff:

Terminal window
chezmoi cd
git diff

Confirm no tokens, caches, or machine paths leaked in, then commit.

Recovery on a New Machine

From a blank Mac:

Terminal window
# 1. Install chezmoi and bitwarden-cli (after Homebrew)
brew install chezmoi bitwarden-cli
# 2. Clone dotfiles to ~/.local/share/chezmoi
chezmoi init [email protected]:yourname/dotfiles.git
# 3. Prepare machine-local chezmoi.toml
mkdir -p ~/.config/chezmoi
vim ~/.config/chezmoi/chezmoi.toml
# 4. Login and unlock Bitwarden
bw login
export BW_SESSION="$(bw unlock --raw)"
# 5. Dry-run first
chezmoi apply --dry-run --verbose
# 6. Apply
chezmoi apply --verbose

Ignore 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 artifacts
node_modules/
package-lock.json
bun.lock
bun.lockb
.pnpm-store/
# local chezmoi config
.chezmoi*.toml
.chezmoi*.yaml

Notes

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-add required, 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.

References

Chezmoi Documentation

Chezmoi: Templates

Chezmoi: Password Managers

Bitwarden CLI