Reproducible Dev Environments with Dev Containers and a Single Script
A single setup script combined with Dev Containers ensures every teammate runs identical toolchains, avoids 'works on my machine', and simplifies onboarding.

New hires spend their first week installing SDKs, runtime versions, and system dependencies, only to find that the project builds on their machine but fails in CI. Inconsistent environments produce "works on my machine" bugs that eat hours of debugging time across the team. A single setup script combined with a Dev Container definition eliminates that variability: every teammate runs the exact same toolchain from the moment they clone the repo.
Why a Reproducible Environment Matters
Onboarding a new developer today often means walking through a multi-page document that lists "install Node.js 18, Python 3.11, Go 1.21, PostgreSQL 15, then run these five commands." Even with the best intentions, people skip steps, use different versions, or install globally conflicting packages. The result is a spectrum of environments that diverge subtly over time. A bug that shows up on one machine but not another triggers a "but it works on my machine" discussion that can take hours to isolate.
A fully reproducible environment reduces that onboarding to a single script execution. The developer runs ./setup.sh, waits for the container to build, and opens the editor inside the same locked-down image that everyone else uses. No manual version checks, no "brew install" variations, no forgotten .tool-versions. The environment becomes code, versioned alongside the project, and changes are reviewed as pull requests. This approach mirrors the philosophy behind tools like Tailwind CSS v4 without a config file, where configuration is embedded in the tooling itself rather than scattered across documents.
Beyond onboarding, consistency matters for debugging production issues. If you can replicate the exact build and runtime environment locally, you reduce the variables that can hide a bug. I've seen teams spend a week chasing a race condition that only appeared on macOS because the Linux containers in CI had a different libc version. A Dev Container would have caught that mismatch on day one.
Dev Containers: The Foundation
Dev Containers are a specification from the Visual Studio Code team that defines how a containerised development environment should be configured. The central file is .devcontainer/devcontainer.json. It declares the container image (or Dockerfile), the extensions to install, post-create commands, and settings overrides. Every VS Code instance that opens the folder—or any editor that supports the devcontainer CLI—reads this file and recreates the environment identically.
Here's a minimal example that pins Node.js 20 and Python 3.11, installs common extensions, and runs npm install after the container starts:
{
"name": "My Project Dev Container",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
"features": {
"ghcr.io/devcontainers/features/python:1": {
"version": "3.11"
}
},
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-python.python"
],
"postCreateCommand": "npm install",
"remoteUser": "node",
"mounts": [
"source=project-node_modules,target=/workspace/node_modules,type=volume"
]
}The image field pins the exact base image. I use tags like :20 instead of :latest to avoid accidental upgrades. The features block installs additional tools (Python, Go, Docker-in-Docker) using the official Dev Container Features registry. The postCreateCommand runs inside the container after it's built, ensuring dependencies are installed fresh.
The remoteUser is critical: it sets the container user to something other than root, preventing the owned-by-root problem I'll cover later. The mounts array uses a named Docker volume for node_modules, which avoids the performance penalty of bind mounts on macOS. This pattern of caching dependencies in named volumes is similar to how Prompt Caching: What Actually Gets Cached and Why Your Hit Rate Is Zero explains that proper storage isolation prevents cache invalidation from unrelated changes.
This file is committed to the repository. Every teammate who opens the folder in VS Code gets prompted to "Reopen in Container". The container image is built from the same Dockerfile or image reference, so the toolchain is identical regardless of the host OS.
The Single Setup Script: What Goes In It
The Dev Container spec handles the environment inside the container, but you still need a reliable way to get the host ready. The setup script checks for prerequisites (Docker, VS Code, git), installs them if missing, and then opens the project inside the container. It also handles one-time setup like cloning dotfiles, configuring git hooks, or setting environment variables for the build.
Here's a practical example for a Unix-based team:
#!/usr/bin/env bash
set -euo pipefail
# Check prerequisites
command -v docker &>/dev/null || { echo "Docker is required. Install from https://docs.docker.com/get-docker/"; exit 1; }
command -v code &>/dev/null || { echo "VS Code is required. Install from https://code.visualstudio.com/"; exit 1; }
# Install devcontainer CLI if not present
if ! command -v devcontainer &>/dev/null; then
echo "Installing devcontainer CLI..."
npm install -g @devcontainers/cli
fi
# Optionally clone or update dotfiles
if [ -n "${DOTFILES_REPO:-}" ]; then
git clone --depth 1 "$DOTFILES_REPO" "$HOME/.dotfiles" 2>/dev/null || (cd "$HOME/.dotfiles" && git pull)
fi
# Setup git hooks
git config core.hooksPath .githooks
# Open the project in a Dev Container
devcontainer open .The script doesn't try to install language runtimes or package managers—that's the container's job. It only ensures the host has Docker and VS Code. The devcontainer open . command builds the container (if needed) and opens VS Code attached to it. For teams using other editors, the script can also print a command to attach manually, e.g., docker exec -it $(docker ps -qf "name=project_devcontainer") /bin/bash.
I include a variable for dotfiles because many teams have shell aliases or git configurations that aren't part of the container image. The script clones them once and updates on subsequent runs. The git hooks path is set to a .githooks directory in the repo, so commit hooks are versioned.
Comparing Dev Containers vs. Nix Shells vs. Docker Compose
Dev Containers aren't the only way to achieve reproducibility. Nix shells and Docker Compose are popular alternatives. Here's how they stack up:
| Feature | Dev Containers | Nix Shells | Docker Compose |
|---|---|---|---|
| Toolchain consistency | Container image pinned | Nix derivation locked | Compose services with images |
| Editor integration | First-class (VS Code), extensions, settings | Manual (shell.nix) | Limited (attach manually) |
| Learning curve | Low (Docker + JSON) | Steep (Nix language) | Medium (Docker + YAML) |
| Multi-service support | Limited (single container, but can use Compose) | Good (multiple shells) | Excellent (native) |
| Deterministic builds | Image tags (good enough) | Fully reproducible | Image tags (good enough) |
| Volume management | Automatic named volumes, bind mounts | Manual (Nix store) | Bind mounts, named volumes |
Nix shells give you a fully deterministic environment—the Nix package manager uses cryptographic hashes to verify every dependency. If you need that level of assurance, Nix is the way to go. But the learning curve is steep: you have to write a shell.nix or flake.nix that defines the exact set of packages and their versions. Editor integration is minimal; you typically run nix-shell and then open your editor from within the shell. For teams that aren't already using Nix, the overhead rarely justifies the benefit.
Docker Compose shines when you need multiple services (a database, a cache, a message queue) alongside your application. You can define each service as a separate container. But Compose doesn't automatically install VS Code extensions, set up postCreateCommand, or manage the container's internal user. You have to wrap those steps in a custom entrypoint script. Dev Containers can actually use a Compose file as the container definition, so you get the best of both: a multi-service setup with full Dev Container integration. This separation of concerns between host orchestration and editor tooling echoes the architecture discussed in Server components vs client islands in Next.js App Router, where different rendering strategies handle different layers of the application.
In practice, I use Dev Containers for the main development environment and fall back to Compose only when the project genuinely requires multiple services running simultaneously. For a typical web app with a single backend, one container is enough.
Combining Script and Container: Hybrid Approach
The setup script and the Dev Container form a complementary pair. The script handles the host; the container handles the workspace. This hybrid approach means the host stays clean—you only install Docker and VS Code, not a mix of language runtimes that might conflict with other projects.
For teams that use multiple editors (IntelliJ, Vim, Emacs), the script can also set up a Makefile target that attaches to the container without VS Code. Here's a common pattern:
.PHONY: shell
shell:
docker run -it --rm -v $(PWD):/workspace -w /workspace my-project-devcontainer /bin/bashThis target uses the same image that the Dev Container builds, but runs it interactively with a shell. The developer can then open their editor of choice on the host filesystem and run commands inside the container. It's not as seamless as the VS Code integration, but it works. I've also seen teams use podman or nerdctl for the same purpose.
The key principle: the container image is the source of truth. Whether you use VS Code, the CLI, or a Makefile, you're always running inside the same environment. The script just reduces friction to get there. This consistency is the same principle behind Adopting React Native New Architecture: Fabric and TurboModules, where the new architecture enforces a uniform bridging layer across platforms.
Failure Modes and Gotchas
Even with a solid setup, a few pitfalls can trip you up.
File permissions on Linux hosts. By default, containers run as root. Files created inside the container (e.g., node_modules, build artifacts) are owned by root on the host. If you need to edit or delete them from the host, you'll get permission errors. The fix is to set the remoteUser property in devcontainer.json to a non-root user that matches the host's UID. The official devcontainer images include a node user with UID 1000. If your host's user has a different UID, you can pass it via a build argument. For example:
{
"build": {
"dockerfile": "Dockerfile",
"args": {
"USER_UID": "1001"
}
},
"remoteUser": "vscode"
}Volume mounts on macOS are slow. Docker for Mac runs a Linux VM, and bind mounts have to cross the hypervisor boundary. If you have a large node_modules directory, npm install can be 10x slower than on Linux. The solution is to use a named Docker volume for the node_modules directory instead of a bind mount. In the devcontainer.json example above, I added a mount for project-node_modules. The container will use that volume, which stays inside the VM and avoids the overhead. You can also use the "workspaceMount": "type=volume,source=my-workspace,target=/workspace" option to mount the entire workspace as a volume.
SSH agent forwarding may fail. If your container needs to clone private repos via SSH, you'll rely on SSH agent forwarding. The Dev Container spec supports this via the mounts property or the sshAgent option. However, on some systems the socket path differs. A common workaround is to explicitly set the SSH agent socket in the container's environment:
{
"mounts": [
"source=/run/host-services/ssh-auth.sock,target=/run/host-services/ssh-auth.sock,type=bind"
],
"containerEnv": {
"SSH_AUTH_SOCK": "/run/host-services/ssh-auth.sock"
}
}This is a common pattern I've seen in Tailscale's devcontainer setup and other advanced configurations.
Network issues behind corporate proxies. If your team works behind a corporate proxy, you'll need to configure the container to use the proxy. Set http_proxy and https_proxy in the containerEnv and also in the Docker daemon config. The script can detect these from the host environment and pass them to the container.
Version drift in the base image. Using image: mcr.microsoft.com/devcontainers/javascript-node:20 pins the major version but not the patch. The image is updated regularly, and a new pull might introduce a different version of Node.js (e.g., 20.11 vs 20.12). To be fully deterministic, pin to a specific digest: image: mcr.microsoft.com/devcontainers/javascript-node:20@sha256:abc123.... I've found that for most teams, the major version pinning is sufficient, but if you're auditing dependencies, digests are the way to go. This trade-off between determinism and convenience mirrors the discussion in Reliable Structured Outputs from LLMs Using JSON Schema, where you balance strict schema validation against practical flexibility.
Key takeaways
- A Dev Container, defined by a
devcontainer.jsonand a base image, acts as a single source of truth for the toolchain. Every teammate gets the same runtimes, extensions, and commands. - The host setup script should only install Docker and VS Code, not language runtimes. It runs
devcontainer opento launch the container. - Use named Docker volumes for directories like
node_moduleson macOS to avoid bind mount performance penalties. - Set
remoteUserto a non-root user indevcontainer.jsonto avoid file permission issues on Linux hosts. - For teams using multiple editors, provide a
Makefiletarget or shell alias that attaches to the same container image, preserving reproducibility regardless of the editor choice.
Frequently asked questions
- Does the setup script run inside the container or on the host?
- The script typically runs on the host to install prerequisites (Docker, VS Code extensions, git hooks) and then launches the container. Inside the container, a second script can handle language-specific tooling. This separation keeps the host setup minimal and the container fully self-contained.
- How do I handle different OS hosts (macOS, Windows, Linux)?
- Dev Containers abstract away the host OS via Docker, so your dev environment is consistent regardless of macOS, Windows, or Linux. However, file permissions, volume mounts, and symlink behavior differ across hosts. The script should set up a `.devcontainer/devcontainer.json` that maps volumes correctly and handles UID/GID remapping for Linux hosts.
- Can I use this setup with editors other than VS Code?
- Dev Containers work best with VS Code, but the container itself is just a Docker image. You can use any editor or terminal by attaching to the container manually with `docker exec`. The setup script can optionally configure JetBrains Gateway or a plain terminal alias for non-VS Code users.