Provision the machine, spec the container: Dotfiles Pro and devcontainers
Devcontainers and a machine provisioner look like competitors, both promise "a working development environment from a config file". They aren't. They own adjacent layers, and they meet in one specific place: the dotfiles hook. This post shows how to use Dotfiles Pro with devcontainers so both layers stay declarative, including the part the container tooling expects you to solve yourself.
Two layers, two owners
A devcontainer spec (.devcontainer/devcontainer.json) lives in a project repository and is owned by that project's team: compilers, language runtimes, project services. It answers "what does this codebase need?", and it answers it identically for every contributor.
Everything below and around the container is the machine, and it answers "what does this developer need?":
- Docker itself: the container has to run somewhere
- git, SSH keys and agent, identity
- the project repositories cloned in the first place
- kubeconfigs, cloud CLIs, and the shell you live in between containers
That lower layer is exactly what the collection provisions: the docker role delivers the runtime, the git role clones the repositories (whose .devcontainer/ specs come along for free), the ssh role loads your keys into an agent that VS Code forwards into containers automatically.
So machine-side, the composition already works with zero glue. The interesting part is what happens inside the container.
The gap: your shell inside the container
Open a devcontainer and you get the project's exact toolchain, and a bare shell. No aliases, no prompt, no muscle-memory. The project spec rightly refuses to carry personal config (it is shared by every contributor), so every devcontainer tool grew the same escape hatch: a personal dotfiles hook.
- VS Code Dev Containers: the
dotfiles.repositorysetting clones your repo into every container it builds and runs an install script. - GitHub Codespaces: the same, as an account-level setting.
- DevPod: the same, via
devpod context set-options -o DOTFILES_URL=….
// VS Code settings.json
{
"dotfiles.repository": "gitlab.com/alice/mydotfiles",
"dotfiles.installCommand": "install.sh"
}All of them expect roughly the same contract: a repo with an install.sh (they probe a few names and fall back to symlinking dot-prefixed files), run as the container user, possibly without sudo, certainly without Python or Ansible. Git identity you don't even need to handle, VS Code copies your .gitconfig into the container and Codespaces injects credentials.
That contract is the honest limit of the Ansible approach: a playbook is the right tool for a long-lived machine and the wrong tool inside someone else's ephemeral project container. The answer is not to force Ansible in, it is to let the same personal repo speak both protocols.
One repo, two entry points
A personal dotfiles repo (see examples/single/) already has the machine entry point: playbook.yaml. Add a container entry point next to it, plain POSIX shell, file-level subset only:
~/.dotfiles/
playbook.yaml ← provisions the machine (Ansible, full scope)
install.sh ← provisions a container (shell, files only)
container/
profile.d/
init.sh
aliases.sh
starship.toml#!/usr/bin/env sh
# install.sh: devcontainer/Codespaces dotfiles hook. No Ansible, no sudo.
set -eu
DOTFILES="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$HOME/.profile.d" "$HOME/.config" "$HOME/.local/bin"
cp "$DOTFILES"/container/profile.d/*.sh "$HOME/.profile.d/"
cp "$DOTFILES"/container/starship.toml "$HOME/.config/starship.toml"
# Hook ~/.profile.d into whichever shell the container image uses
for rc in .bashrc .zshrc; do
[ -f "$HOME/$rc" ] && ! grep -q 'profile.d/init.sh' "$HOME/$rc" &&
printf '\n[ -r "$HOME/.profile.d/init.sh" ] && . "$HOME/.profile.d/init.sh"\n' >> "$HOME/$rc"
done
# Starship is a single static binary, the one install worth doing here
command -v starship >/dev/null 2>&1 ||
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"Note what this reuses: the ~/.profile.d/ convention is the same shell framework the unix base role builds on the machine, so aliases written for one work in the other.
And note what it refuses to do: no package installation, no system config, that belongs to the project's devcontainer.json (use features there) or to the machine playbook.
Or let your agent scaffold it
You are already in the repo with a coding agent. Paste this to have it add the container entry point, and edit the last line to match your shell config:
Copy this prompt to your coding agent
This is my personal dotfiles repo with a playbook.yaml at the root that
provisions the machine. Add a container entry point beside it, plain POSIX
shell, files only — no package installs or system config. Create install.sh
plus a container/ directory (profile.d/ scripts and starship.toml), and make
install.sh symlink container/profile.d/*.sh into ~/.profile.d/ and the
starship config into place, reusing the aliases I already keep for the machine.
Then set "dotfiles.repository" in my VS Code settings to this repo so every
container runs install.sh. Show me install.sh before creating the rest.Same result as the manual scaffold above — the agent writes install.sh and the container/ payload and wires the VS Code hook, while you review the one file that runs inside every container.
One source of truth, eventually
The remaining wrinkle: on the machine, ~/.profile.d/ files are generated by roles from your vars; in container/profile.d/ they are hand-maintained copies. Two roadmap items close that loop:
- render the container payload from the same templates and vars the roles use for the host, so
container/is build output, not a second source of truth; - the
noderole's npm globals will cover thedevcontainerCLI for running specs outside VS Code.
The dividing line
The alternatives comparison scores devcontainers 🟡 on dotfile management for exactly this reason: the hook exists, the payload is your problem. With an install.sh next to your playbook, the same personal repo answers both: the playbook keeps the machine up-to-date, the hook makes every container (local or Codespaces) feel like yours within seconds of Reopen in Container.
Provision the machine with Ansible. Spec the container with devcontainer.json. Let your dotfiles repo speak to both.