Up until now, there’s been an unspoken assumption behind every chezmoi init --apply: the age key is present. That means SSH keys get decrypted and placed, the git remote switches to SSH, and everything just works. On a trusted machine — your main workstation, your server — that’s exactly what you want.
But what about a throwaway dev container? A colleague’s machine where you want your shell config but definitely not your private keys? A CI runner that just needs your .zshrc and .tmux.conf?
Without the age key, chezmoi hits encrypted files it can’t decrypt and fails. The whole apply stops. That’s not graceful — it’s an all-or-nothing setup, and that’s not what we want from a single dotfiles repo.
The Problem, Concretely#
When chezmoi encounters an encrypted file and the age encryption is configured but the key is missing, it errors out:
chezmoi: ~/.ssh/id_ed25519_knuth-info: age: decryption failedThe apply aborts. Your .zshrc, your tmux config, your eza aliases — none of it gets placed because chezmoi gave up on the SSH key it couldn’t decrypt. Everything after the failure is lost.
The fix needs to happen in three places:
- Don’t configure age encryption when the key isn’t there — so chezmoi doesn’t even try to decrypt
- Skip encrypted files — so chezmoi doesn’t attempt to place files it can’t read
- Skip scripts that depend on those files — because switching the git remote to SSH makes no sense without SSH keys
Conditional Encryption in .chezmoi.toml.tmpl#
The first piece is the chezmoi config itself. Right now, .chezmoi.toml.tmpl unconditionally declares age encryption. If the key file doesn’t exist, chezmoi will still try to use it — and fail.
The fix wraps the entire encryption block in a template conditional:
{{- $ageKeyPath := joinPath .chezmoi.homeDir ".config/age/dotfiles-repo-key.txt" -}}
{{- if stat $ageKeyPath -}}
encryption = "age"
[age]
identity = "~/.config/age/dotfiles-repo-key.txt"
recipient = "age1..."
{{- end -}}stat checks whether the file exists. If the age key is at ~/.config/age/dotfiles-repo-key.txt, the encryption config is rendered. If it’s not, the entire block is omitted — chezmoi sees no encryption configuration at all and treats all files as unencrypted. Since there are no encrypted files it needs to process (we’ll handle that next), this is exactly right.
The $ageKeyPath variable avoids repeating the path. Small thing, but it keeps the template readable when the same path appears in the condition and the config value.
Skipping Encrypted Files with .chezmoiignore#
Even with encryption disabled in the config, chezmoi still knows about the encrypted source files — they’re sitting right there in the repo with their encrypted_ prefix. We need to tell chezmoi to skip them entirely when the key isn’t present.
That’s what .chezmoiignore is for. It works like .gitignore, but for chezmoi’s target files — it lists paths that chezmoi should not manage. And like most things in chezmoi, it supports Go templates:
ansible/
{{ if not (stat (joinPath .chezmoi.homeDir ".config/age/dotfiles-repo-key.txt")) }}
.ssh/id_ed25519_knuth-info*
{{ end }}The ansible/ line was already there — we’ve been ignoring the Ansible directory since it’s only used by chezmoi scripts, not placed on the target machine. The new block checks for the age key, and if it’s missing, adds the SSH key files to the ignore list. The wildcard catches both the private key and the .pub file.
When the age key is present, the ignore block evaluates to nothing — chezmoi manages the SSH keys normally, decrypts them, and places them. When the key is absent, chezmoi skips those files entirely. No error, no abort, no attempt to decrypt something it can’t.
Guarding the Git Configuration Script#
There’s one more piece: the run_once_configure-git-repo.sh.tmpl script. This is the script that sets up the git identity for the dotfiles repo and switches the remote from HTTPS to SSH. It depends on the SSH key being in place — without it, configuring core.sshCommand to use a key that doesn’t exist would break git operations.
The solution is the same conditional, but applied differently. Instead of wrapping the script body in an if block, the entire script content is inside the template conditional:
{{- if stat (joinPath .chezmoi.homeDir ".config/age/dotfiles-repo-key.txt") -}}
#!/bin/bash
set -euo pipefail
CHEZMOI_SOURCE="${CHEZMOI_SOURCE_DIR%/}"
SSH_KEY="$HOME/.ssh/id_ed25519_knuth-info"
git -C "$CHEZMOI_SOURCE" config user.name "Marcus Knuth"
git -C "$CHEZMOI_SOURCE" config user.email "github@knuth.info"
git -C "$CHEZMOI_SOURCE" config core.sshCommand "ssh -i $SSH_KEY"
# Switch remote from HTTPS to SSH if needed
CURRENT_URL="$(git -C "$CHEZMOI_SOURCE" remote get-url origin)"
if [[ "$CURRENT_URL" == https://github.com/* ]]; then
SSH_URL="${CURRENT_URL/https:\/\/github.com\//git@github.com:}"
git -C "$CHEZMOI_SOURCE" remote set-url origin "$SSH_URL"
fi
{{- end }}When the age key is missing, the template renders to an empty file — no shebang, no commands, nothing. Chezmoi sees an empty script and skips it. When the key is present, the full script renders and runs as before: git identity configured, remote switched to SSH.
This is a subtle but important pattern. The conditional doesn’t go inside the script — it wraps the script. An empty template output means chezmoi has nothing to execute, which is cleaner than having a script that starts with #!/bin/bash and then immediately exits.
The Same Check, Three Times#
You might notice the same stat check appears in three files:
.chezmoi.toml.tmpl— controls whether encryption is configured.chezmoiignore— controls whether encrypted files are skippedrun_once_configure-git-repo.sh.tmpl— controls whether the git/SSH setup runs
That’s not duplication — it’s three independent decisions that happen to share the same condition. Each file has a different responsibility, and each needs to independently decide whether the age key is available. If you tried to centralize this into a single flag, you’d create a dependency chain between files that chezmoi processes at different stages. Keeping the check local to each file is simpler and more robust.
Testing It#
The easiest way to test this is with our disposable container setup from earlier in the series. Spin up a fresh container without placing the age key:
docker run -it debian:bookworm bashInstall chezmoi and init without the age key. Everything that doesn’t require decryption should apply cleanly: .zshrc, .tmux.conf, zsh plugins, eza aliases, fzf config — all of it. The SSH keys are skipped, the git remote stays on HTTPS, and there’s no error.
Then test the other direction: place the age key at ~/.config/age/dotfiles-repo-key.txt and run chezmoi init --apply again. Now encryption is configured, SSH keys are decrypted and placed, and the git remote switches to SSH. Same repo, same command, different result based on one file’s presence.
What We’ve Achieved So Far#
After chezmoi init --apply, a fresh machine now has:
- zsh installed and set as default shell
- The correct version of age, installed from source
- SSH keys decrypted and in place
- Git identity configured for the dotfiles repo
- SSH key linked to git, remote switched to SSH
- Ansible installed and used to manage software packages
- tmux installed via Ansible
- eza installed from an external apt repository via Ansible
- Onchange script that watches the entire
ansible/directory for changes - Sudo password support for non-root environments
- fzf installed from a GitHub binary release via Ansible
- eza aliases configured in .zshrc — ls, la, lla, and lls
- Pure prompt, autosuggestions, and syntax highlighting — managed via chezmoi externals
- fzf previews with bat (files) and eza (directories)
- tmux configured with sane defaults, vi-style copy, and intuitive splits
- TPM managed via chezmoi externals, with auto-installing resurrect and continuum for persistent sessions
- Conditional encryption: the same repo works with or without the age key
- Encrypted files and SSH-dependent scripts are skipped gracefully on untrusted machines
What’s Next#
The dotfiles repo now handles two modes — trusted machines with full access, and untrusted machines with a graceful subset. But there’s still a gap in the Ansible setup. Right now, the playbook runs everything on every machine. Some tools — like tmux or fzf — make sense everywhere. Others might not. Next up: making the Ansible playbook aware of context, so it can adapt what it installs based on where it’s running.
