How to Sync Dotfiles Across Multiple DevContainers
You work in several projects, each its own devcontainer, and you want your prompt, aliases, and git config identical in all of them. This page uses the spec's dotfiles keys so a single repository is cloned and applied on every container create — the same baseline everywhere — with a bootstrap kept idempotent and $HOME-relative so it works regardless of the project.
The task matters because a devcontainer is disposable by design: it is rebuilt from an image whenever you clone a new project, bump the base tag, or your teammate opens the same repo on their machine. Anything you configure by hand inside a running container — a ~/.zshrc tweak, a git alias, a prompt theme — evaporates the moment that container is discarded. Without a sync mechanism you either accept a bare shell in every fresh environment, or you re-apply the same edits by hand dozens of times a week. Pointing the dotfiles keys at one repository turns that manual toil into a single step the container tooling runs for you, so the environment you have carefully tuned follows you into every project instead of staying trapped in one.
Reach for this when you notice the same setup drifting between containers: gs works in the project you configured last week but not in the one you opened today, or your git identity is set in one and empty in another. The mental model is a fan-out from one source. You maintain exactly one dotfiles repository; the VS Code Dev Containers extension clones it into each container's $HOME on create and runs your install command; the install command is written so that running it against an already-configured home directory changes nothing. Because the setting lives at the user level rather than in any one devcontainer.json, a project you have never touched picks up the same baseline the first time you open it, whether the container mounts a Node service, a Python monorepo, or a Rust workspace.
Prerequisites
You need one dotfiles repo and an idempotent, path-portable bootstrap.
- A single dotfiles repository holding your baseline.
- An idempotent
installentry point (chezmoi, Stow, or guarded script). - Only
$HOME-relative paths — nothing project-specific.
The detail people get wrong is treating the install command as a one-time setup script rather than something that runs on every create. The install entry point is not a first-run installer; the extension executes it each time a container is built, so it must be safe to run against a home directory that may already carry a previous run's results. That is why chezmoi, GNU Stow, and a hand-written guarded script all qualify while a naive cp or unconditional >> append does not — the former converge on a desired state, the latter accumulate duplicates. Equally, the repository must hold your baseline and nothing tied to a single machine: no absolute paths under /Users/you or /home/you, no per-project directory names, only files and links anchored to $HOME. Keep secrets out of it entirely, since a dotfiles repo you point every container at is the last place you want a token committed.
Step-by-Step Implementation
- Point every devcontainer at the same dotfiles repo (or set it once in user settings so it applies to all).
{
"dotfiles": {
"repository": "https://github.com/you/dotfiles",
"installCommand": "install.sh"
},
"remoteUser": "vscode"
}
The dotfiles.repository value tells the extension which repo to clone into the container, and installCommand names the script to run once the clone lands in $HOME. Setting these inside a devcontainer.json pins the baseline for this one project, which is useful when you want to prove the mechanism works. The remoteUser key matters more than it looks: the clone and install run as that user, so their $HOME is where your dotfiles land. If the install writes to the wrong home directory — because the container actually runs as root while you assumed vscode — the files appear somewhere your shell never reads, and the baseline looks like it silently failed. Naming the user explicitly keeps the target home directory predictable.
- Set it globally in VS Code so it applies to every container without per-repo config.
{
"dotfiles.repository": "you/dotfiles",
"dotfiles.installCommand": "install.sh"
}
This is the version you actually want for syncing across many projects. These are VS Code user settings, not workspace settings, so they live in your personal settings.json and apply to every container you open regardless of what its devcontainer.json contains. The moment you clone a brand-new repository and reopen it in a container, the extension reads these user-level keys and applies your baseline — no per-repo edit required, which is exactly the fan-out this whole exercise is built around. Note the shorter you/dotfiles form: the extension expands a bare owner/repo into a full GitHub URL, so you can keep the setting terse. Because this config is not committed to any project, it also stays yours alone — teammates who open the same repos get their own dotfiles from their own user settings, not yours.
- Keep the bootstrap idempotent and $HOME-relative so it works in any project.
grep -qxF 'source ~/.dotfiles/common.sh' ~/.zshrc || echo 'source ~/.dotfiles/common.sh' >> ~/.zshrc
This one line is the idempotency pattern in miniature. grep -qxF tests whether the exact source ~/.dotfiles/common.sh line already exists in ~/.zshrc: -F treats the pattern as a fixed string so the slashes and dots match literally, -x requires the whole line to match so a partial or commented occurrence does not count, and -q suppresses output because only the exit status matters. Only when that test fails does the || branch append the line. Run it on a fresh container and the line is added once; run it again after a reload and nothing changes. That guard prevents the classic failure where a bootstrap that blindly appends turns ~/.zshrc into a file with the same source line repeated a dozen times, slowing every shell start. Every path here is anchored to ~, so the snippet behaves identically no matter which project's container it runs in.
- Verify the same baseline applied across two different containers.
type gs && test -L ~/.gitconfig && echo "baseline applied"
The verification checks two independent signals that the baseline actually took hold, joined by && so the success message only prints when both hold. type gs confirms your shell knows the gs alias or function, which proves your interactive shell config — the ~/.zshrc that sources your common file — was loaded, not merely cloned. test -L ~/.gitconfig confirms ~/.gitconfig is a symbolic link, which is the tell that a tool like Stow or chezmoi linked your managed git config into place rather than leaving the container's default file untouched. Running this exact command in two different project containers is the real test of sync: identical output in both means one repository truly produced one baseline. If type gs fails, the clone or install did not run for the user whose shell you are in; if the test -L fails, your git config is not the managed one and something wrote a plain file over the link.
Common Pitfalls
Cross-container problems are usually project-specific paths or a non-idempotent bootstrap.
The most common ownership trap shows up when a private dotfiles repo is cloned but the container cannot authenticate. The Dev Containers extension forwards your host git credentials and SSH agent, so a repo that clones on your laptop usually clones inside the container too — but a headless build or a base image running as root while your keys sit under a different user's home will fail silently, leaving a bare shell and no obvious error. When you sync private dotfiles, make the credential explicit: a token in the host or Codespaces secret store, never a key committed into the dotfiles repo itself. The same care applies to file ownership after the clone — if the install command runs as one user but the mounted $HOME is owned by another, the resulting ~/.zshrc may be unreadable to your interactive shell even though the files are physically present.
The subtler pitfall is a bootstrap that is idempotent on one machine but not portable across projects. A script that hardcodes ~/work/api/scripts or assumes a node_modules sibling will run cleanly in the project it was written for and quietly do the wrong thing everywhere else, which is why the symptom "works in one project, not another" almost always traces to a path that should have been $HOME-relative. Keep anything that references a specific repo's layout out of your personal dotfiles and inside that project's own devcontainer.json or postCreateCommand, so the shared baseline stays free of project coupling and the same clone lands identically in every container.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Baseline missing in some containers | dotfiles set per-repo, not globally | Set dotfiles at the VS Code user level |
| Works in one project, not another | Project-specific path hardcoded | Use only $HOME-relative paths |
| Config doubles across rebuilds | Non-idempotent bootstrap | Guard appends or use chezmoi |
| Private repo won't clone | No auth in the container | Use a token/SSH the container can access |
Conclusion
Set your dotfiles once at the user level so every devcontainer clones the same baseline, and keep the bootstrap idempotent and $HOME-relative so it applies cleanly in any project. One repository becomes the single source of truth for your environment across every container you open.
The strategic payoff is that your personal environment stops being a per-container liability and becomes a versioned asset. When the baseline lives in one repo that every container clones on create, improving your setup is a single commit — a new alias, a better prompt, a fixed git default — and the change reaches the next container you open in every project without you touching a single devcontainer.json. That is the same pin-and-cache discipline that governs reproducible images, applied to the human layer: just as you pin a base image so builds are deterministic, you pin your dotfiles to one source so your shell is deterministic. The install command is your cache-safe apply step, converging on a known state rather than mutating whatever it finds.
Framed that way, cross-container dotfiles sync is really about separating what is yours from what is the project's. The project owns its toolchain, its postCreateCommand, and any repo-specific paths; you own one portable, idempotent baseline that rides into every container without colliding with a teammate's preferences. Keep that boundary clean and the reproducibility you expect from your images extends to the environment you actually type into — the same prompt, aliases, and git identity in every container you open.
FAQ
How do I apply dotfiles to every container without configuring each repo?
Set the dotfiles repository and install command in VS Code's user settings (dotfiles.repository, dotfiles.installCommand) rather than in each devcontainer.json. The extension then clones and applies your baseline on every container create, across all projects, with no per-repo configuration. Because user settings are not committed to any repository, this keeps the mechanism entirely personal: you and a teammate can open the same project and each get your own baseline. The bare owner/repo form is expanded into a full GitHub URL, so the setting stays short, and any project that has its own devcontainer.json dotfiles keys will override yours only for that one repo.
Why does my setup work in one project but not another?
Almost always a project-specific path baked into the bootstrap. Use only $HOME-relative paths so the same dotfiles apply regardless of the project's layout. Anything that references a particular repo's directory structure belongs in that project's config, not your personal dotfiles. A quick way to catch these is to grep your dotfiles for absolute paths and directory names that only make sense in one repo; if the install script assumes a sibling folder or a specific mount point, it will run cleanly where it was written and silently misbehave everywhere else. Move that logic into the project's postCreateCommand and keep the shared baseline strictly portable.
Can I sync private dotfiles?
Yes, but the container needs credentials to clone the private repo — a token or SSH key made available via the host/Codespaces secret store, never committed. Once the container can authenticate, private dotfiles sync exactly like public ones. In local Dev Containers the extension forwards your host SSH agent and git credential helper, so a repo you can already clone on the host usually clones inside the container without extra work. The failures show up in headless or root-only environments where those forwarded credentials are not present, which is where an explicit token from the secret store earns its keep.
Does the install command run on every rebuild or only the first time? Every time a container is created or rebuilt, not just once. That is precisely why the bootstrap must be idempotent: the extension re-clones the dotfiles repo and re-runs your install command on each create, so a script that appends unconditionally will duplicate lines and a script that converges on a desired state will leave a correctly configured home directory untouched. Treat the install command as a repeatable apply step, never a one-shot installer.
What is the difference between the dotfiles keys and a postCreateCommand?
The dotfiles keys carry your personal baseline and are best set at the user level so they follow you into every project. A postCreateCommand lives in a project's devcontainer.json and carries that project's setup — installing dependencies, seeding a database, generating a config. Keeping the two separate is the boundary that makes sync work: personal preferences in one portable repo, project-specific steps in the project, so neither contaminates the other.
Related
- Up to Automating Dotfiles Sync Across Containers — the parent guide on dotfiles.
- Bootstrapping Dotfiles with chezmoi in a DevContainer — an idempotent tool for the bootstrap.
- Shell Environment Customization: zsh, fish, bash — the shell your dotfiles configure.