Dev Containers What · Why · When · How — with production-ready samples
Development containers give every engineer on your team an identical, reproducible, fully-configured development environment — regardless of their host OS, installed tools, or machine. Defined as code, rebuilt in seconds, and version-controlled with the project.
Why Dev Containers?
The classic "works on my machine" problem destroys developer productivity. Onboarding takes days. OS-specific bugs hide in production. Subtle version differences between engineers cause mysterious test failures. Dev containers solve all of this.
Every developer gets precisely the same runtime, tools, extensions, and configuration. No manual setup steps. No "install Node 20 but some people have 18" drift.
A new engineer clones the repo, opens VS Code, clicks "Reopen in Container" and has a fully functional development environment in minutes — not days.
Project tools, runtimes, and dependencies live inside the container. Your host machine stays clean. Switching between projects with conflicting requirements is trivial.
The dev environment is defined in .devcontainer/devcontainer.json — committed to version control, code-reviewed, and updated just like application code.
The same devcontainer.json powers GitHub Codespaces, VS Code Remote SSH, and CI pipelines — one definition, used everywhere.
Pin the exact image, tool versions, and extension list. Check out any historical commit and recreate its exact development environment years later.
Traditional Setup vs Dev Container
| Dimension | Traditional Setup | Dev Container |
|---|---|---|
| Onboarding time | Hours to days of manual steps | Minutes — clone and reopen |
| Environment parity | "Works on my machine" syndrome | Guaranteed identical for all developers |
| Dependency conflicts | Global tool versions clash between projects | Isolated per project inside container |
| Documentation | README setup guides that go stale | devcontainer.json — always current, executable |
| OS compatibility | Different scripts for Mac, Linux, Windows | Single definition works everywhere |
| CI parity | CI environment differs from local | Same container used locally and in CI |
| Extension consistency | Each dev has different VS Code extensions | Extensions defined and auto-installed |
| Host machine health | Accumulates global tools, versions, clutter | Host stays clean — tools in container |
When To Use Dev Containers
- Team spans multiple OS (Windows, macOS, Linux)
- Project has specific runtime/tool version requirements
- Onboarding new engineers frequently
- Project has complex dependencies (databases, queues, etc.)
- You use GitHub Codespaces for cloud development
- CI should use the same environment as local dev
- You maintain multiple projects with conflicting tool versions
- Security posture benefits from isolating project from host
- You want extensions and editor settings standardised
- Solo developer, single OS, simple project with no conflicts
- Team uses powerful native builds where Docker overhead matters
- Project requires host GPU access (gaming, native ML — check GPU passthrough)
- Offline development where pulling images is not possible
- Native iOS/macOS development requiring Xcode (macOS-specific)
- Team lacks Docker fundamentals and learning curve is too high
How Dev Containers Work
When you open a project in VS Code with a .devcontainer/devcontainer.json, the Dev Containers extension builds (or pulls) a Docker image, starts a container, mounts your project source code into it, and then runs VS Code Server inside the container. Your local VS Code connects to this remote process — giving you full IntelliSense, debugging, and terminal access running inside the container environment.
┌─ Your Machine (host) ──────────────────────────────────────────┐ │ │ │ VS Code UI ←──── local rendering ────────────────────────┐ │ │ ↕ (extension protocol) │ │ │ ┌─ Docker Container ─────────────────────────────────┐ │ │ │ │ │ │ │ │ │ VS Code Server (runs inside container) │ │ │ │ │ ├─ Language servers (LSP) │ │ │ │ │ ├─ Debugger │ │ │ │ │ ├─ Terminal (bash/zsh inside container) │ │ │ │ │ └─ Extensions (installed inside container) │ │ │ │ │ │ │ │ │ │ /workspaces/my-project ←── bind mount ──────────────── │ │ │ │ Node 22, Python 3.12, .NET 8, Go 1.23, etc. │ │ │ │ │ git, gh CLI, custom tools │ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ │ Source files stay on host disk — edited locally by VS Code │ └─────────────────────────────────────────────────────────────────┘
File Structure
The Dev Container specification looks for configuration in these locations, in order of precedence. Most projects use .devcontainer/devcontainer.json.
project-root/ ├── .devcontainer/ │ ├── devcontainer.json # ← primary config (most common) │ ├── Dockerfile # custom image (optional) │ ├── docker-compose.yml # multi-service (optional) │ └── scripts/ │ └── post-create.sh # setup script called by postCreateCommand │ ├── .devcontainer/name/ # multiple named configs (pick at open time) │ └── devcontainer.json │ ├── .devcontainer.json # root-level (single file, less common) │ └── src/ ... # your project source
devcontainer.json Reference
The devcontainer.json file is a JSON-with-Comments (JSONC) file. It describes the container image, tools to install, VS Code extensions, port forwarding, lifecycle scripts, and more.
{
"name": "My Project Dev", // Display name in VS Code UI
// ── IMAGE / BUILD SOURCE (pick one) ─────────────────
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
// OR
"build": {
"dockerfile": "Dockerfile",
"context": "..",
"args": { "VARIANT": "22-bookworm" }
},
// OR for Docker Compose
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"runServices": ["db", "redis"],
// ── FEATURES ────────────────────────────────────────
"features": {
"ghcr.io/devcontainers/features/node:1": { "version": "22" },
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
// ── WORKSPACE ────────────────────────────────────────
"workspaceFolder": "/workspaces/my-project",
// ── PORT FORWARDING ──────────────────────────────────
"forwardPorts": [3000, 5432, 6379],
"portsAttributes": {
"3000": { "label": "App", "onAutoForward": "openBrowser" },
"5432": { "label": "PostgreSQL", "onAutoForward": "silent" }
},
// ── ENVIRONMENT ──────────────────────────────────────
"containerEnv": {
"NODE_ENV": "development",
"DATABASE_URL": "postgresql://user:pass@db:5432/mydb"
},
"remoteEnv": {
"LOCAL_WORKSPACE": "${localWorkspaceFolder}" // host path
},
// ── USER ─────────────────────────────────────────────
"remoteUser": "node", // run VS Code Server as this user
"updateRemoteUserUID": true, // sync UID/GID with host (Linux)
// ── MOUNTS ───────────────────────────────────────────
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,readonly",
"source=my-volume,target=/data,type=volume"
],
// ── LIFECYCLE SCRIPTS ────────────────────────────────
"onCreateCommand": "npm install",
"updateContentCommand": "npm install",
"postCreateCommand": "bash .devcontainer/scripts/post-create.sh",
"postStartCommand": "git config core.autocrlf false",
"postAttachCommand": { // parallel execution (object form)
"info": "echo 'Container ready!'",
"check": "node --version"
},
// ── VS CODE CUSTOMIZATION ────────────────────────────
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"terminal.integrated.defaultProfile.linux": "bash"
}
}
},
// ── CONTAINER OPTIONS ────────────────────────────────
"runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"],
"hostRequirements": { "memory": "4gb", "cpus": 2 }
}
Key Properties Reference
| Property | Type | Purpose |
|---|---|---|
| name | string | Display name shown in VS Code and Codespaces UI |
| image | string | Docker image reference — DockerHub, GHCR, MCR, ACR |
| build.dockerfile | string | Path to Dockerfile relative to devcontainer.json |
| dockerComposeFile | string | array | Docker Compose file(s) for multi-service setups |
| service | string | Which Compose service VS Code connects to |
| features | object | Add tooling Features from the OCI registry |
| forwardPorts | number[] | Ports auto-forwarded to localhost on open |
| portsAttributes | object | Labels and auto-forward behavior per port |
| containerEnv | object | Static env vars baked into the container at creation |
| remoteEnv | object | Dynamic env vars for terminals and VS Code processes only |
| remoteUser | string | User VS Code Server and terminals run as inside container |
| mounts | string[] | Additional bind mounts or named volumes |
| workspaceFolder | string | Path inside container where VS Code opens (default: /workspaces/name) |
| customizations.vscode.extensions | string[] | Extensions auto-installed in the container |
| customizations.vscode.settings | object | VS Code settings scoped to this container |
| runArgs | string[] | Extra Docker run arguments (capabilities, security opts) |
| hostRequirements | object | Minimum host memory/CPU — used by Codespaces for sizing |
| updateRemoteUserUID | boolean | Sync container user UID/GID with host user (Linux only) |
Variable Substitution
| Variable | Resolves to |
|---|---|
${localWorkspaceFolder} | Path to project folder on host machine |
${containerWorkspaceFolder} | Path to project inside the container |
${localEnv:HOME} | Host environment variable (e.g., /home/alice) |
${containerEnv:PATH} | Container environment variable |
${localWorkspaceFolderBasename} | Project folder name only (e.g., my-project) |
Dev Container Features
Features are self-contained, shareable units of installation code. Reference them by OCI identifier in "features": {} — no Dockerfile editing needed. They run as a secondary build step and can contribute lifecycle scripts, environment variables, and more.
Popular Official Features
| Feature | OCI identifier | What it installs |
|---|---|---|
| Node.js | ghcr.io/devcontainers/features/node:1 | Node.js, npm, nvm. Accepts version, nodeGypDependencies |
| Python | ghcr.io/devcontainers/features/python:1 | Python, pip, pyenv. Accepts version |
| Go | ghcr.io/devcontainers/features/go:1 | Go toolchain. Accepts version |
| Rust | ghcr.io/devcontainers/features/rust:1 | Rust, cargo, rustup |
| Java | ghcr.io/devcontainers/features/java:1 | JDK via SDKMAN. Accepts version, jdkDistro |
| .NET | ghcr.io/devcontainers/features/dotnet:2 | .NET SDK. Accepts version |
| Git | ghcr.io/devcontainers/features/git:1 | Latest git, git-lfs, config helpers |
| GitHub CLI | ghcr.io/devcontainers/features/github-cli:1 | gh CLI tool |
| Docker-in-Docker | ghcr.io/devcontainers/features/docker-in-docker:2 | Full Docker daemon inside the container |
| Docker-outside-Docker | ghcr.io/devcontainers/features/docker-outside-docker:1 | Docker socket bind mount (simpler) |
| kubectl | ghcr.io/devcontainers/features/kubectl-helm-minikube:1 | kubectl, helm, optional minikube |
| Terraform | ghcr.io/devcontainers/features/terraform:1 | Terraform, tflint, terragrunt |
| Azure CLI | ghcr.io/devcontainers/features/azure-cli:1 | az CLI |
| AWS CLI | ghcr.io/devcontainers/features/aws-cli:1 | AWS CLI v2 |
"features": { // Specific version "ghcr.io/devcontainers/features/node:1": { "version": "22", "nodeGypDependencies": true }, // Latest (shorthand) — equivalent to { "version": "latest" } "ghcr.io/devcontainers/features/git:1": "latest", // Multiple tools in one file "ghcr.io/devcontainers/features/python:1": { "version": "3.12" }, "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/terraform:1": { "version": "latest" }, // Local Feature (during development) "./my-local-feature": {} }
Lifecycle Hooks
Lifecycle scripts run at specific points in the container's life. They are executed inside the container from the workspace folder. If a script fails, subsequent scripts are skipped.
| Hook | Runs when | Frequency | Use for |
|---|---|---|---|
| initializeCommand | On host, before container starts | Every start | Host-side validation, creating local dirs |
| onCreateCommand | Inside container, after creation | Once at creation | Tool configuration, one-time installs |
| updateContentCommand | After content update (prebuilds) | When content changes | Dependency install tied to project files |
| postCreateCommand | After all create commands finish | Once at creation | npm/pip install, DB migrations, .env setup |
| postStartCommand | Every container start | Every start | Start a dev server, apply git config |
| postAttachCommand | When a tool attaches to the container | Each attach | Show welcome messages, open browser |
// String — runs in /bin/sh "postCreateCommand": "npm install && npm run build", // Array — executes directly (no shell, preserves quotes) "postCreateCommand": ["bash", "scripts/setup.sh"], // Object — runs all commands in PARALLEL "postCreateCommand": { "npm": "npm install", "pip": "pip install -r requirements.txt", "seed": "bash scripts/seed-db.sh" }, // Recommended: delegate to a script for complex setups "postCreateCommand": "bash .devcontainer/scripts/post-create.sh"
Ports & Mounts
Port Forwarding
forwardPorts makes container ports appear as localhost on your host. More secure than appPort (published ports) because it works with apps that only listen on localhost.
"forwardPorts": [3000, 5432, 6379], "portsAttributes": { "3000": { "label": "App Server", "onAutoForward": "openBrowser" }, "5432": { "label": "PostgreSQL", "onAutoForward": "silent" } }
onAutoForward options: openBrowser · openBrowserOnce · openPreview · notify · silent · ignore
Mounts
Mount SSH keys, Docker socket, named volumes, or additional host directories using the mounts array.
"mounts": [ // SSH keys (read-only for safety) "source=${localEnv:HOME}/.ssh,\ target=/home/node/.ssh,\ type=bind,readonly", // Named volume (persists between rebuilds) "source=my-app-node_modules,\ target=/workspaces/app/node_modules,\ type=volume", // Docker socket (Docker-outside-Docker) "source=/var/run/docker.sock,\ target=/var/run/docker.sock,\ type=bind" ]
Image vs Dockerfile vs Compose
| Approach | Use when | Pros | Cons |
|---|---|---|---|
| image | Pre-built image covers all needs; use Features for extras | Simplest. Fast start — no build step. MCR images are maintained. | Less control over exact image contents |
| build (Dockerfile) | Custom OS packages, specific tool versions, or complex setup | Full Dockerfile control. Layer caching. Can share with production. | Must maintain Dockerfile. Slightly slower first-time build. |
| dockerComposeFile | Project needs multiple services (DB, cache, queue) during dev | Full multi-service parity with production. Services auto-start. | Most complex to configure. Compose file to maintain. |
Microsoft Base Images (MCR)
| Image | Tag pattern | Includes |
|---|---|---|
mcr.microsoft.com/devcontainers/base | :ubuntu :debian :alpine | Base OS + common tools (git, curl, zsh, etc.) |
mcr.microsoft.com/devcontainers/javascript-node | :22 :20 | Node.js LTS, npm, nvm, yarn |
mcr.microsoft.com/devcontainers/python | :3.12 :3.11 | Python, pip, venv, pylint, black |
mcr.microsoft.com/devcontainers/dotnet | :8.0 :9.0 | .NET SDK, C# tools |
mcr.microsoft.com/devcontainers/go | :1.23 | Go, gopls, delve debugger |
mcr.microsoft.com/devcontainers/rust | :latest | Rust toolchain, cargo, rust-analyzer |
mcr.microsoft.com/devcontainers/java | :21 | Java JDK, Maven, Gradle |
mcr.microsoft.com/devcontainers/universal | :linux | Node, Python, Go, Java, .NET, Ruby, PHP — used by Codespaces default |
Sample: Node.js / TypeScript
node_modules volume for performance.{
"name": "Node.js TypeScript",
// Use MCR Node.js image — maintained, updated regularly
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
// Add the GitHub CLI Feature
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},
// Auto-forward dev server and Vite HMR ports
"forwardPorts": [3000, 5173],
"portsAttributes": {
"3000": { "label": "Dev Server", "onAutoForward": "openBrowser" },
"5173": { "label": "Vite HMR", "onAutoForward": "notify" }
},
// node_modules in a volume = much faster I/O on macOS/Windows
"mounts": [
"source=${localWorkspaceFolderBasename}-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
],
// Install deps on container creation
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint", // ESLint
"esbenp.prettier-vscode", // Prettier
"ms-vscode.vscode-typescript-next", // Latest TS
"bradlc.vscode-tailwindcss", // Tailwind IntelliSense
"christian-kohler.path-intellisense",
"formulahendry.auto-rename-tag"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"typescript.preferences.importModuleSpecifier": "non-relative"
}
}
},
"remoteUser": "node"
}
Sample: Python / FastAPI
{
"name": "FastAPI Development",
// Use Compose for multi-service (app + db + redis)
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"runServices": ["db", "redis"],
"workspaceFolder": "/workspaces/api",
"shutdownAction": "stopCompose",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"forwardPorts": [8000, 5432, 6379],
"portsAttributes": {
"8000": { "label": "FastAPI", "onAutoForward": "openBrowser" },
"5432": { "label": "PostgreSQL", "onAutoForward": "silent" },
"6379": { "label": "Redis", "onAutoForward": "silent" }
},
"containerEnv": {
"DATABASE_URL": "postgresql+asyncpg://devuser:devpass@db:5432/devdb",
"REDIS_URL": "redis://redis:6379",
"PYTHONDONTWRITEBYTECODE": "1"
},
// Install deps + run migrations on creation
"postCreateCommand": {
"pip": "pip install -r requirements.txt",
"hooks": "pre-commit install"
},
"postStartCommand": "alembic upgrade head",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.mypy-type-checker",
"ms-python.black-formatter",
"charliermarsh.ruff",
"tamasfe.even-better-toml",
"cweijan.vscode-postgresql-client2"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"editor.formatOnSave": true,
"[python]": { "editor.defaultFormatter": "ms-python.black-formatter" }
}
}
}
}
version: '3.9' services: app: build: { context: ., dockerfile: Dockerfile } volumes: - ../..:/workspaces/api:cached # project root → container command: /bin/sh -c "while sleep 1000; do :; done" depends_on: [db, redis] db: image: postgres:16 environment: POSTGRES_USER: devuser POSTGRES_PASSWORD: devpass POSTGRES_DB: devdb volumes: - postgres-data:/var/lib/postgresql/data redis: image: redis:7-alpine volumes: postgres-data:
Sample: .NET 8 / C#
{
"name": ".NET 8 API",
"build": {
"dockerfile": "Dockerfile"
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/node:1": { "version": "20" } // for tooling
},
"forwardPorts": [5000, 5001, 1433],
"portsAttributes": {
"5000": { "label": "API (HTTP)", "onAutoForward": "openBrowser" },
"5001": { "label": "API (HTTPS)", "onAutoForward": "silent" },
"1433": { "label": "SQL Server", "onAutoForward": "silent" }
},
"containerEnv": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ConnectionStrings__Default": "Server=localhost;Database=DevDb;User=sa;Password=YourStr0ng!Password;TrustServerCertificate=true"
},
"postCreateCommand": {
"restore": "dotnet restore",
"tools": "dotnet tool restore"
},
"postStartCommand": "dotnet dev-certs https --trust 2>/dev/null || true",
"customizations": {
"vscode": {
"extensions": [
"ms-dotnettools.csharp",
"ms-dotnettools.csdevkit",
"ms-dotnettools.dotnet-interactive-vscode",
"ms-mssql.mssql",
"humao.rest-client",
"editorconfig.editorconfig"
],
"settings": {
"editor.formatOnSave": true,
"[csharp]": {
"editor.defaultFormatter": "ms-dotnettools.csharp"
},
"dotnet.defaultSolution": "MyApi.sln"
}
}
},
// Required for dotnet debugging attach
"runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"]
}
FROM mcr.microsoft.com/devcontainers/dotnet:8.0 # Install EF Core tools and other global .NET tools RUN dotnet tool install --global dotnet-ef \ && dotnet tool install --global dotnet-outdated-tool \ && dotnet tool install --global csharpier # Ensure tools are on PATH ENV PATH="${PATH}:/root/.dotnet/tools"
Sample: Multi-Service Stack
version: '3.9' services: # ── Backend API (VS Code connects here) ──────────── api: build: { context: ../../api, dockerfile: .devcontainer/Dockerfile } volumes: ["../..:/workspaces:cached"] command: sleep infinity environment: NODE_ENV: development DATABASE_URL: postgresql://dev:dev@db:5432/appdb REDIS_URL: redis://redis:6379 depends_on: [db, redis] ports: ["3001:3001"] # ── Frontend SPA ─────────────────────────────────── web: build: { context: ../../web, dockerfile: .devcontainer/Dockerfile } volumes: ["../..:/workspaces:cached"] command: sleep infinity ports: ["5173:5173"] # ── Shared services ──────────────────────────────── db: image: postgres:16 environment: { POSTGRES_USER: dev, POSTGRES_PASSWORD: dev, POSTGRES_DB: appdb } volumes: ["pgdata:/var/lib/postgresql/data"] redis: image: redis:7-alpine nginx: image: nginx:alpine volumes: ["./nginx.conf:/etc/nginx/nginx.conf"] ports: ["80:80"] depends_on: [api, web] volumes: pgdata:
{
"name": "API Service",
"dockerComposeFile": "../../.devcontainer/docker-compose.yml",
"service": "api",
"runServices": ["db", "redis", "nginx"],
"workspaceFolder": "/workspaces/api",
"forwardPorts": [3001, 5432, 6379, 80],
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode",
"cweijan.vscode-postgresql-client2"]
}
}
}
Sample: GitHub Codespaces
hostRequirements, Codespaces-specific settings, and prebuild strategy are used for cloud optimization.{
"name": "My App (Codespaces)",
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
// Minimum hardware for this project (Codespaces uses this for machine sizing)
"hostRequirements": {
"cpus": 4,
"memory": "8gb",
"storage": "32gb"
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"forwardPorts": [3000, 5000],
// onCreateCommand runs during prebuild — heavy work done before you open
"onCreateCommand": "npm install",
// updateContentCommand runs when source changes during prebuild
"updateContentCommand": "npm install",
// postCreateCommand runs AFTER prebuild merge — after you open the Codespace
"postCreateCommand": "npm run db:migrate",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
},
// Codespaces-specific: GitHub Copilot integration
"codespaces": {
"openFiles": ["README.md", "src/index.ts"]
}
}
}
onCreateCommand and updateContentCommand on a schedule. This means opening a Codespace takes seconds instead of minutes — deps are already installed when the image is ready.Dev Container CLI
The devcontainer CLI is the official command-line tool for building and running dev containers outside of VS Code — perfect for CI pipelines, scripting, and server-side use.
# Install globally via npm npm install -g @devcontainers/cli # Build and open a project in a container devcontainer up --workspace-folder ./my-project # Execute a command inside a running container devcontainer exec --workspace-folder . -- npm test # Build the container image (without starting) devcontainer build --workspace-folder . --image-name myapp:dev # Run feature tests devcontainer features test --workspace-folder . --features ./my-feature # Rebuild from scratch (no cache) devcontainer up --workspace-folder . --remove-existing-container
Using Dev Containers in CI (GitHub Actions)
name: CI in Dev Container on: push: branches: [main] pull_request: jobs: build-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 # Build and run commands inside the dev container - name: Build and test in dev container uses: devcontainers/ci@v0.3 with: imageName: ghcr.io/my-org/my-app-devcontainer cacheFrom: ghcr.io/my-org/my-app-devcontainer push: filter # push image on main branch only refFilterForPush: refs/heads/main runCmd: | npm run lint npm test npm run build
Tips & Tricks
| Tip | Detail |
|---|---|
| Use named volumes for node_modules | On macOS/Windows, bind mounts to node_modules are very slow. Mount a named Docker volume over it: source=proj-node_modules,target=.../node_modules,type=volume |
| Pin image tags, not :latest | Use :22-bookworm not :latest. Floating tags can break your environment on the next pull. Use Dependabot for the docker ecosystem to update automatically. |
| SSH keys via bind mount | Mount your ~/.ssh directory as readonly: "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,readonly". Never bake SSH keys into images. |
| Share git config | Mount ~/.gitconfig from the host: "source=${localEnv:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,readonly" |
| Use postCreateCommand for deps | Run npm install / pip install in postCreateCommand, not in the Dockerfile. This ensures deps track your lockfile and are re-installed when the lockfile changes. |
| Parallel postCreateCommand | Use the object form of lifecycle commands to run independent tasks in parallel: { "npm": "npm install", "pip": "pip install -r requirements.txt" } |
| Delegate to a setup script | For complex setup, write .devcontainer/scripts/post-create.sh and call it. Easier to maintain, testable, and readable vs a long inline command string. |
| Rebuild vs Reopen | "Reopen in Container" reuses the existing container. "Rebuild Container" builds a fresh one — use this after changing the Dockerfile or Features. "Rebuild Without Cache" forces full rebuild. |
| Multiple configs for a mono-repo | Create .devcontainer/frontend/devcontainer.json and .devcontainer/backend/devcontainer.json. VS Code will prompt you to choose at open time. |
| Secrets in containers | Never put secrets in containerEnv or the Dockerfile. Use remoteEnv to pull from host environment variables, or mount a .env file via postCreateCommand. |
Anti-Patterns & Pitfalls
| Anti-pattern | Problem | Fix |
|---|---|---|
| npm install in Dockerfile | Dependencies cached in the image — don't update when lockfile changes. Slow to rebuild. | Run npm install in postCreateCommand instead. Image just provides Node. |
| Using :latest tags | Container silently breaks when the upstream image changes on next pull or rebuild. | Pin to a specific version tag. Use Dependabot docker ecosystem to keep it updated. |
| Secrets in containerEnv | containerEnv is stored in image metadata — visible in Compose files, devcontainer.json, CI logs. |
Use remoteEnv with ${localEnv:MY_SECRET} or mount a gitignored .env file. |
| Everything in one huge Dockerfile | Hard to maintain. Changes to one tool invalidate all layer cache. Slow rebuilds. | Use Features for standard tooling. Only put project-specific layers in the Dockerfile. |
| Skipping forwardPorts | Services only listen on localhost inside the container — not reachable from host browser. |
Declare all dev server ports in forwardPorts. They'll be accessible on host localhost automatically. |
| Running as root | Files created in the container are owned by root — permission issues on host files. | Set "remoteUser": "vscode" (or appropriate non-root user). Set updateRemoteUserUID: true on Linux. |
| Giant monolithic devcontainer.json | Hard to review, hard to understand, slows onboarding documentation value. | Split complex setups into named configs (.devcontainer/backend/, .devcontainer/frontend/). |
| Not committing devcontainer.json | The container config exists only locally — no team benefit, defeats the purpose. | Always commit .devcontainer/ to git. It is team infrastructure, not a personal preference file. |
Dev Container Checklist
Click each item to track your configuration review.
- devcontainer.json committed — in
.devcontainer/and tracked by version control. - Image tag pinned — not
:latest. Using a specific version like:22or:3.12-bookworm. - Dependency install in lifecycle hook —
npm install/pip installinpostCreateCommand, not in the Dockerfile. - No secrets in containerEnv — using
remoteEnvwith${localEnv:}substitution or mounted .env files. - forwardPorts configured — all dev server ports declared so they're accessible from the host browser.
- remoteUser set — non-root user specified.
updateRemoteUserUID: trueset for Linux hosts. - Extensions declared — team-standard VS Code extensions in
customizations.vscode.extensions. - Editor settings enforced — format-on-save, default formatter, and language settings in
customizations.vscode.settings. - node_modules volume (if Node project) — named volume over
node_modulesfor macOS/Windows performance. - SSH keys mounted (if needed) — readonly bind mount of
~/.ssh, not baked into image. - Dockerfile minimal (if using one) — only project-specific layers; use Features for standard tooling.
- Tested on a clean machine — another team member has opened the container from scratch and confirmed it works.