“Works on my machine” has been the punchline of software engineering for decades, but the joke stopped being funny around the time teams went fully distributed. Onboarding a new developer involves a README, a wiki page, three Slack messages, and a ritual of installing language versions, system libraries, and database drivers — all before the first commit compiles. Dev containers aim to eliminate that ritual entirely.
A development container is a Docker container configured via a devcontainer.json file that lives in your repository. It defines the runtime, tools, extensions, environment variables, and lifecycle scripts needed to work on the codebase. The Development Container Specification is an open standard maintained by the dev containers community, and it’s supported by VS Code, GitHub Codespaces, JetBrains, DevPod, and the standalone Dev Container CLI.
The core promise: clone the repo, open it, and the exact same development environment boots on every machine — with zero manual setup.
The Anatomy of devcontainer.json
At its simplest, a dev container points at a base image and declares a few settings. Here’s a minimal configuration for a TypeScript project:
{
"name": "TypeScript Service",
"image": "mcr.microsoft.com/devcontainers/typescript-node:20",
"forwardPorts": [3000],
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}
}
}
That’s it. When a developer opens this project in a supported editor, the container builds (or pulls), port 3000 forwards to the host, and the specified extensions install automatically. The image field references one of the prebuilt images from Microsoft’s dev container registry, which bundles the language runtime, common tools, and a non-root user configured for development.
Features: Modular Tool Installation
Hardcoding everything into a custom Dockerfile gets unwieldy fast. Dev Container Features solve this by letting you compose tools as modular units. Each Feature installs a specific tool — Docker-in-Docker, the GitHub CLI, a language runtime, a database client — without modifying the base image:
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.24"
}
}
}
Features are referenced by their OCI registry path and version. The dev container runtime resolves them, executes their install scripts during the build phase, and the result is cached as a layer. You can browse hundreds of community Features on the official registry.
Lifecycle Hooks: From postCreateCommand to postStartCommand
Dev containers define several lifecycle hooks that run at different stages of container creation. Getting these right is the difference between a fast-booting container and one that reinstalls dependencies on every restart:
- initializeCommand — runs on the host before the container builds. Useful for pulling git submodules or generating config files.
- onCreateCommand — runs once when the container is first created. Heavy operations like installing global tools go here.
- updateContentCommand — runs when the workspace content is synced (including subsequent rebuilds). Use it for cache-busting operations.
- postCreateCommand — runs after the user environment is set up but only once. Ideal for
npm ci,go mod download, or generating language servers. - postStartCommand — runs every time the container starts. Light tasks only — starting a file watcher, clearing temp files.
- postAttachCommand — runs each time an editor attaches. Useful for starting a development server.
A practical example combining these for a Go project:
{
"image": "mcr.microsoft.com/devcontainers/go:1.24",
"postCreateCommand": "go mod download && go install github.com/air-verse/air@latest",
"postStartCommand": "air",
"forwardPorts": [8080]
}
Dependencies download once on creation. The air file watcher boots on every start, giving you live reload without touching the Dockerfile.
Docker Compose: Multi-Container Dev Environments
Real applications rarely run alone. If your service talks to PostgreSQL, Redis, and a message broker, you need all of them running during development. Dev containers integrate with Docker Compose natively:
{
"name": "Full Stack Dev",
"dockerComposeFile": "docker-compose.dev.yml",
"service": "app",
"workspaceFolder": "/workspace",
"forwardPorts": [3000, 5432, 6379]
}
The service field tells the dev container runtime which Compose service is the “primary” one — that’s where your editor and terminal attach. The other services (database, cache, etc.) boot as dependencies. The docker-compose.dev.yml file is a standard Compose file:
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ../..:/workspaces:cached
command: sleep infinity
depends_on:
- db
- cache
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: dev
volumes:
- pgdata:/var/lib/postgresql/data
cache:
image: redis:7-alpine
volumes:
pgdata:
The sleep infinity command keeps the container alive without starting a specific process — the editor manages the actual workload.
Reproducibility with Lockfiles
One subtlety with Features is that they’re resolved at build time. If a Feature’s maintainer publishes a patch, the next person who builds the container gets a different environment. The Dev Container CLI solves this with lockfiles — a .devcontainer-lock.json file pins the exact Feature version that was used during the last successful build:
devcontainer build --workspace-folder .
The lockfile is generated by default during build and up commands. Use --frozen-lockfile in CI to enforce that the lockfile matches the Feature declarations — any drift fails the build. This gives you the same reproducibility guarantee that package-lock.json provides for npm.
CI Integration: Use the Same Container Everywhere
The real power of dev containers is using the exact same environment in CI that developers use locally. Instead of maintaining a separate Dockerfile for CI, you build from the devcontainer.json:
npm install -g @devcontainers/cli
devcontainer up --workspace-folder . --remove-existing-container
devcontainer exec --workspace-folder . go test ./...
devcontainer exec --workspace-folder . go build ./cmd/server
In a GitHub Actions workflow, this means your CI and your local development run identical toolchains. No more “tests pass in CI but fail locally” or vice versa.
Beyond VS Code: DevPod and the CLI
The dev container standard isn’t locked to VS Code. DevPod is an open-source tool that runs dev containers in any editor — Vim, Neovim, IntelliJ, even a plain terminal. It handles container creation, workspace mounting, and SSH connectivity without requiring VS Code at all.
The Dev Container CLI itself is editor-agnostic. You can script it in a Makefile or shell script for headless use. The standalone install script bundles its own Node.js runtime, so you don’t need Node installed on the host:
curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh
Practical Tips for Adoption
- Start with a prebuilt image. Microsoft maintains base images for dozens of stacks at
mcr.microsoft.com/devcontainers/. Start there and add Features as needed rather than writing a Dockerfile from scratch. - Keep postCreateCommand idempotent. It runs once on creation, but if someone rebuilds the container, it runs again. Commands like
npm ciandgo mod downloadare safe to repeat. - Mount your SSH and git config. Dev containers support mounting host files via the
mountsproperty. Mounting~/.sshand~/.gitconfigmakes git operations seamless inside the container. - Commit the lockfile. The
.devcontainer-lock.jsonensures every team member and every CI run gets the same Feature versions. Add it to version control. - Use separate dev and production Dockerfiles. Dev containers optimize for developer experience (larger images, more tools). Production containers optimize for size and security. Don’t conflate the two.
Wrapping Up
Dev containers replace fragile setup documentation with a reproducible, version-controlled environment definition. The devcontainer.json file becomes the single declaration of what a project needs to run — runtime, tools, extensions, dependencies, and infrastructure. Once committed, any team member or CI system can boot into the identical environment with a single command.
The ecosystem has matured significantly. Features provide modular tool composition, lockfiles ensure reproducibility, Docker Compose integration handles multi-service stacks, and tools like DevPod extend support beyond any single editor. If you’re still maintaining a multi-page onboarding wiki, it’s time to replace it with a devcontainer.json.