Bootstrapping Dotfiles with chezmoi in a DevContainer
chezmoi makes dotfiles idempotent by design — it reconciles your home directory to a declared state — which is exactly what a devcontainer needs, since the bootstrap re-runs on every create. This page wires chezmoi into the dotfiles install command, uses its templating for per-host values, and keeps secrets out of the repo.
The reason this matters is that a devcontainer is not a stable machine you set up once. It is a disposable environment that gets rebuilt whenever you rebuild the image, change a feature, or open the workspace on a fresh Codespaces host. The dotfiles install command runs each of those times, so anything it does has to survive being repeated. A naive cp or ln -s script accumulates side effects: it clobbers files it did not create, fails when a symlink already exists, or silently leaves stale config behind when you rename a file in the repo. chezmoi sidesteps that entire class of problem because it does not "run steps" — it computes the difference between your declared source state and the actual home directory, then applies only what changed. The tenth chezmoi apply produces the same home directory as the first.
Reach for chezmoi when your dotfiles have grown past a handful of static files — when you need the same repo to render a different git email inside a work container than on your laptop, when a file should only exist on Linux, or when part of your shell config depends on a token you must not commit. Those are the cases where a plain symlink farm breaks down. The mental model to hold is that chezmoi owns your home directory's managed files, the devcontainer's install command is just the thing that invokes chezmoi apply, and everything host-specific flows through templates and an external secret source rather than through branches in a script.
Prerequisites
You need a chezmoi-managed dotfiles repo and chezmoi available in the container.
- A dotfiles repo initialized with chezmoi (
chezmoi init). - chezmoi installed in the image or fetched by the bootstrap.
- Any secrets sourced from the secret store, not committed.
The one prerequisite people underestimate is the difference between a directory of dotfiles and a chezmoi-managed repo. chezmoi does not read your files by their real names; it reads a source directory where the names are encoded — dot_gitconfig for ~/.gitconfig, a .tmpl suffix for templated files, private_ and encrypted_ prefixes for permission and secret handling. Running chezmoi init in an existing dotfiles repo sets up that source layout, and chezmoi add ~/.gitconfig is how you bring a real file under management with the correct encoded name. If you point the install command at a plain repo that was never initialized with chezmoi, chezmoi apply has nothing to reconcile and quietly does nothing.
The second detail worth checking before you start is where chezmoi will live in the container. The bootstrap runs as remoteUser — vscode in the config below — so chezmoi must be either baked into the image on that user's PATH or fetched into a writable location the user owns. Installing it into $HOME/.local/bin keeps it inside the user's home and avoids needing root at apply time, which matters because the dotfiles install command is not guaranteed to run with sudo.
Step-by-Step Implementation
- Set the install command to chezmoi apply, fetching chezmoi if needed.
{
"dotfiles": {
"repository": "https://github.com/you/dotfiles",
"installCommand": "install.sh"
},
"remoteUser": "vscode"
}
This block points the devcontainer's dotfiles integration at your repo and names install.sh as the installCommand, rather than letting the tooling guess. Naming it explicitly means chezmoi's invocation lives in a script you control instead of the default behavior of symlinking every top-level file, which would fight with chezmoi's own placement. Setting remoteUser to vscode tells the platform which account owns the applied files, so the home directory reconciled is the one you log in as — not root. Get that wrong and chezmoi writes to /root, and your vscode shell starts with none of the config you just installed.
- Bootstrap chezmoi idempotently in the install script.
#!/usr/bin/env bash
set -euo pipefail
command -v chezmoi >/dev/null || sh -c "$(curl -fsLS get.chezmoi.io)" -- -b "$HOME/.local/bin"
"$HOME/.local/bin/chezmoi" init --apply --source "$PWD"
Every line here earns its place. set -euo pipefail makes the script fail loudly on the first error, an unset variable, or a broken pipe, so a half-finished bootstrap never masquerades as success. The command -v chezmoi >/dev/null || guard is what keeps the install idempotent at the tool level: on the second create chezmoi is already present, so the curl | sh install is skipped instead of re-downloading and re-writing the binary. When it does run, -b "$HOME/.local/bin" targets a user-writable directory that matches the remoteUser from step one, avoiding a permission failure on the write. The final line does the real work — init --apply initializes the source state and reconciles the home directory in a single pass, and --source "$PWD" points at the checked-out dotfiles repo rather than chezmoi's default ~/.local/share/chezmoi, so it uses the exact tree the devcontainer just cloned. Omitting --source is the classic failure mode: chezmoi looks in its default location, finds nothing, and applies an empty state.
- Template per-host values so one repo adapts to each environment.
# dot_gitconfig.tmpl
[user]
email = {{ .email }}
The dot_gitconfig.tmpl name carries two instructions at once. The dot_ prefix tells chezmoi the target is ~/.gitconfig, and the .tmpl suffix tells it to render the file through its template engine before writing, stripping the suffix from the destination. Inside, {{ .email }} pulls from chezmoi's template data — values you set in ~/.config/chezmoi/chezmoi.toml, supply through --promptString, or read from the environment — so the same committed file produces you@work.com in the work container and your personal address on your laptop. This replaces per-host branches: instead of maintaining a gitconfig.work and a gitconfig.personal and choosing between them in the script, you keep one templated source and let the data decide. The concrete value never appears in git history because only the template and its placeholder are committed.
- Verify the applied state is correct and re-running is a no-op.
chezmoi apply && chezmoi apply # second run makes no changes
Running chezmoi apply twice back to back is the cheapest possible idempotency test, and it exercises the exact property the devcontainer relies on. The first invocation reconciles whatever drifted; the second must change nothing. If the second run still writes files, something is non-deterministic — usually a template that reads a timestamp or a random value, or a wrapper that mutates a file chezmoi also manages. For a sharper look, chezmoi apply --dry-run --verbose prints exactly what it would change without touching disk, and chezmoi doctor flags a missing binary, an unreadable source, or a template that fails to render. Catching that at build time is far cheaper than discovering on your fifth rebuild that the bootstrap has been quietly rewriting your ~/.gitconfig every create.
Common Pitfalls
chezmoi issues come from committing secrets or a non-idempotent install wrapper.
The permission angle bites when chezmoi's binary or source lands in a location the remoteUser cannot write. If the bootstrap installs chezmoi to $HOME/.local/bin but an earlier layer ran as root and left $HOME owned by root:root, the -b install fails with a permission error, or worse, succeeds under sudo and produces files the vscode user cannot read. When chezmoi's config directory is mounted from a cached volume, the same mismatch means chezmoi cannot write ~/.config/chezmoi/chezmoi.toml, so your template data never persists between rebuilds. The fix is to chown $HOME and any mounted config path to the remoteUser before the install command runs, so every chezmoi write happens as the account that will later read those files.
The subtler pitfall is a wrapper that duplicates work chezmoi already does. It is tempting to add ln -s, cp, or git config --global lines to install.sh "just to be safe," but each of those touches a file chezmoi also manages, and now two owners fight over the same target. On the next chezmoi apply chezmoi sees the file diverge from its declared state and rewrites it, so the second run is never a no-op and the idempotency guarantee is gone. Keep the wrapper thin: its only jobs are to make chezmoi available and to call init --apply. Let chezmoi own every managed file, and move anything host-specific into templates or run-once scripts (run_once_*) inside the source tree rather than into shell lines around the apply.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Second apply still changes files | Wrapper script not idempotent | Let chezmoi own state; thin wrapper only |
| Secret ended up in the repo | Committed a real value | Use chezmoi's template/secret integration |
| Wrong email/host value | Template data not set | Provide template data via chezmoi config |
| chezmoi not found on create | Not installed before apply | Install chezmoi in the bootstrap first |
Conclusion
chezmoi turns the devcontainer's re-running bootstrap from a liability into a non-issue: chezmoi apply reconciles your home to a declared state, so the tenth run equals the first. Use its templating for per-host values and its secret integration to keep tokens out of the repo, and dotfiles become truly idempotent.
The strategic payoff is that your environment configuration becomes reproducible in the same way your image is. Pinning a base image and caching layers makes the system deterministic; declaring your home directory with chezmoi extends that determinism to the user layer on top. A new teammate cloning the repo, a rebuilt Codespace, and your own machine all converge on the same shell, git identity, and editor config from the same source — the only variation is the template data and secrets each environment supplies. That is the same pin-and-declare discipline the rest of the devcontainer already uses, applied to files that usually get left as an untracked afterthought.
It also keeps the failure surface small over time. Because the wrapper is thin and chezmoi owns state, adding a new dotfile is a matter of chezmoi add and a commit, not another fragile line in a bootstrap script that has to be tested against the re-run case. When a value needs to differ per host you reach for a template; when it is a secret you reach for the secret integration; and in both cases the repo stays clean and the apply stays idempotent. The result scales from one container to a fleet without the config drift that eventually forces a manual "fix my shell" pass in every environment.
FAQ
Why chezmoi over a hand-written install script?
Because chezmoi is idempotent by design — chezmoi apply reconciles your home directory to the declared source state, so re-running it (which the devcontainer does on every create) never duplicates or corrupts anything. A hand-written script must reproduce that guarantee manually with guards on every mutation, and every new file is another mutation that needs a guard. chezmoi also gives you templating, secret integration, and file-permission control for free — features you would otherwise reimplement badly. The trade is learning the source-state naming convention in exchange for never debugging a re-run again.
How does chezmoi handle per-machine differences?
Through templating. Files ending in .tmpl are rendered with data you provide (via chezmoi's config or prompts), so one repository produces host-appropriate output — different git emails, OS-specific paths — without branching. This keeps a single source of truth adaptable across environments. The template data comes from chezmoi.toml, from --promptString on first init, or from the environment, and chezmoi also exposes built-in variables like .chezmoi.os and .chezmoi.hostname so you can gate a whole block on the operating system. Inside a container you typically feed the data from a devcontainer environment variable so no interactive prompt blocks the automated bootstrap.
How do I keep secrets out of a chezmoi repo?
Use chezmoi's integration with password managers and secret tooling, or its encrypted-file support, so the repo references a secret rather than storing it. In a devcontainer, source the secret from the host/Codespaces secret store at apply time. Never commit a real credential. Concretely, a template can call {{ (bitwarden ...) }} or read an environment variable that the platform injects, so the rendered file on disk holds the secret while the committed source holds only the reference. If you must store an encrypted file in the repo, chezmoi's encrypted_ prefix with an age or GPG key keeps the ciphertext in git and decrypts at apply time, but the decryption key still has to reach the container out of band.
Does chezmoi need to run as root in the container?
No, and it generally should not. chezmoi writes into the remoteUser's home directory, so it only needs write access to that home and to wherever the binary is installed. Installing to $HOME/.local/bin and applying as vscode keeps the whole flow rootless. If you see permission errors, the usual cause is an earlier root-owned layer leaving $HOME unwritable, not chezmoi needing elevation.
Will chezmoi remove a file after I delete it from the source?
Only if you tell it to. By default chezmoi manages the files present in its source and does not track deletions, so removing dot_gitconfig from the repo leaves the existing ~/.gitconfig in place on the next apply. To have a file actively removed you add a remove_ entry so the declared state says "this must not exist." A stale file that lingers across rebuilds can be just as confusing as one that keeps changing.
How does this interact with the devcontainer's own dotfiles feature?
The devcontainer dotfiles integration is only the trigger: it clones your repo and runs the installCommand you name, with no opinion about chezmoi. That is why you point installCommand at install.sh and let that script drive chezmoi init --apply — you keep the platform's simple clone-and-run contract while chezmoi does the reconciliation the default file-symlinking would otherwise get wrong.
Related
- Up to Automating Dotfiles Sync Across Containers — the parent guide on dotfiles.
- How to Sync Dotfiles Across Multiple DevContainers — applying the baseline everywhere.
- Shell Environment Customization: zsh, fish, bash — the shell chezmoi configures.