Dev Containers: Reproducible Development Environments That Actually Work
Dev Containers: Reproducible Development Environments That Actually Work
Every team has the same problem: a new developer joins, spends a day installing the right versions of Node, Python, Postgres, Redis, and a dozen other tools, then hits a platform-specific bug that takes another half-day to resolve. Dev Containers solve this by defining your entire development environment -- runtime, tools, extensions, settings -- in a JSON file that produces an identical container on any machine.
The Dev Container specification is an open standard, originally created by Microsoft for VS Code, now supported by GitHub Codespaces, JetBrains IDEs, DevPod, and the devcontainer CLI. This guide covers practical setup, customization, and the patterns that make Dev Containers worth the initial investment.
How It Works
A Dev Container is a Docker container configured for development. Your editor connects to the container and runs your code inside it. The project files are mounted from your host machine (or cloned into the container), so you edit locally but execute in a controlled environment.
The configuration lives in .devcontainer/devcontainer.json at the root of your repository. When you open the project in VS Code (or any supporting tool), it reads this file, builds or pulls the container, and connects to it.
Minimal Setup
Basic devcontainer.json
{
"name": "My Project",
"image": "mcr.microsoft.com/devcontainers/typescript-node:22",
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": [
"biomejs.biome",
"bradlc.vscode-tailwindcss"
]
}
}
}
This does three things:
- Uses a pre-built image with Node.js 22 and TypeScript tooling
- Runs
npm installafter the container is created - Installs VS Code extensions inside the container
Create this file, open the project in VS Code, and click "Reopen in Container" when prompted. Everything else happens automatically.
Using a Custom Dockerfile
For more control, use a Dockerfile:
.devcontainer/
devcontainer.json
Dockerfile
# .devcontainer/Dockerfile
FROM mcr.microsoft.com/devcontainers/base:ubuntu
# Install project-specific tools
RUN apt-get update && apt-get install -y \
postgresql-client \
redis-tools \
&& rm -rf /var/lib/apt/lists/*
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
{
"name": "My Project",
"build": {
"dockerfile": "Dockerfile"
},
"postCreateCommand": "bun install",
"customizations": {
"vscode": {
"extensions": [
"biomejs.biome"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome"
}
}
}
}
Dev Container Features
Features are reusable, shareable units of configuration. Instead of writing Dockerfile instructions for common tools, you declare features:
{
"name": "Full Stack Project",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "22"
},
"ghcr.io/devcontainers/features/python:1": {
"version": "3.12"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/aws-cli:1": {}
}
}
Each feature installs and configures a specific tool. This is composable -- add the features your project needs without maintaining a complex Dockerfile. The official features registry at ghcr.io/devcontainers/features covers most common development tools.
Community features extend the ecosystem further:
{
"features": {
"ghcr.io/devcontainers-contrib/features/bun:1": {},
"ghcr.io/devcontainers-contrib/features/mise-asdf:1": {},
"ghcr.io/devcontainers-extra/features/playwright:1": {}
}
}
Multi-Container Setup with Docker Compose
Real applications need databases, caches, and other services. Use Docker Compose to run them alongside your dev container:
# .devcontainer/docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ..:/workspace:cached
command: sleep infinity
depends_on:
- db
- redis
db:
image: postgres:16
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pgdata:
{
"name": "Full Stack App",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"postCreateCommand": "npm install && npm run db:migrate",
"forwardPorts": [3000, 5432, 6379],
"customizations": {
"vscode": {
"extensions": [
"biomejs.biome",
"ckolkman.vscode-postgres"
]
}
}
}
When VS Code opens this project, it starts Postgres, Redis, and the development container together. Port forwarding makes the services accessible from your host machine for tools like database GUIs.
Lifecycle Commands
Dev Containers support several lifecycle hooks:
{
"initializeCommand": "echo 'Running on host before container starts'",
"onCreateCommand": "echo 'Container created for the first time'",
"updateContentCommand": "echo 'Runs after clone/pull in container'",
"postCreateCommand": "npm install && npm run db:setup",
"postStartCommand": "echo 'Container started (every time)'",
"postAttachCommand": "echo 'Editor attached to container'"
}
The most commonly used:
- postCreateCommand: install dependencies, run migrations, set up the project. Runs once after container creation.
- postStartCommand: start background services. Runs every time the container starts.
Environment Variables and Secrets
{
"containerEnv": {
"NODE_ENV": "development",
"DATABASE_URL": "postgres://dev:dev@db/myapp"
},
"remoteEnv": {
"GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}"
}
}
containerEnv sets environment variables inside the container. remoteEnv pulls values from the host machine, which is useful for secrets you do not want to commit. The ${localEnv:VAR} syntax reads from the host's environment.
For secrets that vary per developer, use a .env file that is gitignored:
{
"runArgs": ["--env-file", ".devcontainer/.env"]
}
Practical Configuration Patterns
Python Project
{
"name": "Python API",
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"postCreateCommand": "pip install uv && uv sync",
"customizations": {
"vscode": {
"extensions": [
"charliermarsh.ruff",
"ms-python.python",
"ms-python.mypy-type-checker"
],
"settings": {
"python.defaultInterpreterPath": ".venv/bin/python",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true
}
}
}
}
}
Rust Project
{
"name": "Rust Project",
"image": "mcr.microsoft.com/devcontainers/rust:latest",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"postCreateCommand": "cargo build",
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer",
"vadimcn.vscode-lldb",
"tamasfe.even-better-toml"
],
"settings": {
"rust-analyzer.check.command": "clippy"
}
}
}
}
Go Project
{
"name": "Go Service",
"image": "mcr.microsoft.com/devcontainers/go:1.22",
"postCreateCommand": "go mod download",
"customizations": {
"vscode": {
"extensions": ["golang.go"],
"settings": {
"go.lintTool": "golangci-lint"
}
}
}
}
The devcontainer CLI
You do not need VS Code to use Dev Containers. The CLI works standalone:
# Install the CLI
npm install -g @devcontainers/cli
# Build the container
devcontainer build --workspace-folder .
# Start and connect
devcontainer up --workspace-folder .
# Run a command inside the container
devcontainer exec --workspace-folder . npm test
# Use in CI
devcontainer exec --workspace-folder . bash -c "npm ci && npm test && npm run build"
This makes Dev Containers useful in CI pipelines -- your CI environment matches your development environment exactly.
Performance Considerations
Dev Containers add overhead. Here is how to minimize it:
File system performance: On macOS and Windows, Docker's file system mount is slow. Use the :cached mount flag or, better, clone the repository inside the container volume:
{
"workspaceMount": "",
"workspaceFolder": "/workspace",
"initializeCommand": "git clone https://github.com/org/repo /tmp/repo || true",
"postCreateCommand": "cp -r /tmp/repo /workspace && npm install"
}
Image caching: Use a specific image tag rather than latest to leverage Docker's layer cache. Pre-build images with your dependencies installed for faster startup.
Pre-building: For teams, pre-build Dev Container images in CI and push to a registry:
devcontainer build \
--workspace-folder . \
--image-name ghcr.io/myorg/devcontainer:latest \
--push true
Then reference the pre-built image in devcontainer.json for instant startup.
When Dev Containers Are Worth It
Use Dev Containers when:
- Your project requires specific tool versions and multiple runtimes
- New team members spend significant time on environment setup
- "Works on my machine" is a recurring problem
- You want to enforce consistent editor settings and extensions across the team
- You need Linux-specific tools but developers use macOS or Windows
Skip Dev Containers when:
- Your project is a single-language, single-tool setup (just a
package.jsonorpyproject.toml) - Your team is small and all using the same OS
- Docker is not available in your environment
- Performance overhead is unacceptable for your workflow
The Bottom Line
Dev Containers trade a one-time configuration investment for eliminating environment setup problems permanently. The devcontainer.json file becomes the single source of truth for what your project needs to run, and every developer gets an identical environment regardless of their host OS. If your team's onboarding document is longer than half a page of setup instructions, a Dev Container will pay for itself immediately.