Dev Container — A unified Mac / Windows / Linux dev environment
Use VS Code Dev Containers to erase OS differences. A Docker-based, reproducible dev env defined by a single devcontainer.json the whole team shares.
"It works on my machine" usually starts with OS differences. Mac has brew and Apple Silicon, Windows has PowerShell and UTF-16, Linux machines have systemd. Even when everyone installs the same Node version and packages, subtle differences appear.
Dev Containers (VS Code Dev Containers + the standard devcontainer.json) seal OS differences inside a Docker container. The whole team works inside the same Linux environment and only the IDE runs on the host. This guide sets up a single dev container that runs the same on Mac, Windows, and Linux hosts.
TL;DR
- Install a Docker runtime (Mac: OrbStack/Docker Desktop, Win: Docker Desktop/WSL Docker, Linux: docker)
- VS Code + the Dev Containers extension (
ms-vscode-remote.remote-containers) - Drop one
.devcontainer/devcontainer.jsonat the project root Cmd/Ctrl+Shift+P→ Dev Containers: Reopen in Container — IDE relaunches inside the container- Teammate: clone → run that command once → identical environment
Prerequisites
- VS Code 1.95+
- Docker runtime — per-OS guides: mac/docker-setup · windows/docker-wsl2
- Project under Git (so devcontainer.json can be shared)
1. Why Dev Containers
Problems it solves
- "It works on my machine" — inside the container, OS and toolchain are identical
- Onboarding speed — new hires don't read a 30-line README; they click once
- Version conflicts — Project A on Node 18, B on Node 22 — isolated without host mise/nvm
- OS-specific build problems — sidesteps Apple Silicon ARM issues, Windows long-path issues, etc.
- Matches CI — use the same base image as your GitHub Actions workflow and the gap disappears
Where Dev Containers are weak
- GUI app development — Mac-native or Windows-native UI builds don't belong in a container
- GPU-heavy workloads — some GPU passthrough is possible, but running on the host is more stable
- Low-spec machines — Docker itself eats 1–2GB extra RAM (fine on 16GB)
2. Minimal devcontainer.json
.devcontainer/devcontainer.json:
{
"name": "My Project Dev",
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
"features": {
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"postCreateCommand": "npm install",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}
},
"forwardPorts": [3000, 5173],
"remoteUser": "node"
}Key fields:
| Field | Role |
|---|---|
image | Docker base image (Node / Python / Go / Rust, etc.) |
features | Add-on tools (git, gh, docker-in-docker, awscli — pickable from a catalog) |
postCreateCommand | Runs once after container creation (install deps, etc.) |
customizations.vscode.extensions | VS Code extensions auto-installed inside the container |
forwardPorts | Ports exposed to the host |
remoteUser | User inside the container (non-root recommended for security) |
3. First run
VS Code Command Palette (Cmd/Ctrl+Shift+P) → Dev Containers: Reopen in Container.
- First run: ~1–3 minutes (image pull +
postCreateCommand) - Bottom-left of VS Code shows
Dev Container: My Project Dev
# Terminal inside the container (VS Code Terminal)
uname -a
# Linux 1234abcd 6.10.x #1 SMP Debian ...
which node
# /usr/local/share/nvm/versions/node/v22.x/bin/nodeSame Linux environment regardless of the host OS.
4. Picking a base image
The image: in devcontainer.json. Official catalog:
| Language / stack | Image |
|---|---|
| Node.js | mcr.microsoft.com/devcontainers/javascript-node:1-{18,20,22}-bookworm |
| Python | mcr.microsoft.com/devcontainers/python:1-{3.11,3.12,3.13}-bookworm |
| Go | mcr.microsoft.com/devcontainers/go:1-{1.22,1.23} |
| Rust | mcr.microsoft.com/devcontainers/rust:1-bookworm |
| Java | mcr.microsoft.com/devcontainers/java:1-{17,21}-bookworm |
| .NET | mcr.microsoft.com/devcontainers/dotnet:1-9.0-bookworm |
| Universal (polyglot) | mcr.microsoft.com/devcontainers/universal:2-linux |
Recommendation: single-language project → that language's image. Multi-language → a base image + features.
Or write your own Dockerfile:
{
"name": "My Custom Dev",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
}
}5. features — add a tool with one line
The features block uses modules from the official catalog (https://containers.dev/features). One line and the tool is installed.
Common picks:
"features": {
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}, // run docker inside the container
"ghcr.io/devcontainers/features/aws-cli:1": {},
"ghcr.io/devcontainers/features/terraform:1": {},
"ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {}
}6. Docker-in-Docker — containers inside the container
When you need to docker run inside the dev container (testing docker-compose, build verification):
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "latest",
"dockerDashComposeVersion": "v2"
}
}Or share the host's Docker socket (lighter but weaker security):
"mounts": [
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
]7. Host ↔ container file mounts
The workspace folder is auto-mounted at /workspaces/{project}.
Additional mounts:
"mounts": [
"source=${localEnv:HOME}/.aws,target=/home/node/.aws,type=bind,readonly",
"source=${localEnv:HOME}/.gitconfig,target=/home/node/.gitconfig,type=bind"
]For macOS mount performance, see docker-setup §5.2 (VirtioFS recommended).
8. Env vars and secrets
Static env vars
"containerEnv": {
"NODE_ENV": "development",
"LOG_LEVEL": "debug"
}Secrets (never commit)
Mount a .env file or pass through via localEnv:
"containerEnv": {
"GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}",
"OPENAI_API_KEY": "${localEnv:OPENAI_API_KEY}"
}Host shell env vars are injected into the container. Keep .env in .gitignore.
9. Multi-container (docker-compose integration)
For dependencies like DB / Redis:
.devcontainer/docker-compose.yml:
services:
app:
image: mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm
volumes:
- ..:/workspaces/myapp
command: sleep infinity
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: dev
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:.devcontainer/devcontainer.json:
{
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/myapp"
}VS Code attaches to the app container; db is started automatically.
10. Same env as CI — devcontainer-cli
Reuse devcontainer.json in CI:
npm install -g @devcontainers/cli
# Build the container and run a command
devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . npm testGitHub Actions:
# .github/workflows/test.yml
- name: Test in dev container
uses: devcontainers/ci@v0.3
with:
runCmd: npm testCI green = same result locally.
11. Verify
# Host
docker --version
# Inside the container (VS Code Terminal)
uname -a # Linux ...
which node # /usr/local/share/...
echo $NODE_ENV # development (containerEnv took effect)
node --version # the container's Node version
git --version # installed via featuresAll five responding inside the container = setup complete.
12. Troubleshooting
"Failed to connect" / container fails to start
- Is the Docker runtime up? (Docker Desktop menu bar,
orb status,colima status) docker psto spot a port/name collision- VS Code → Command Palette → "Dev Containers: Rebuild Without Cache"
Builds take forever
- First build pulls the base image (multi-GB) — expected
- Subsequent builds use the cache.
postCreateCommandis not cached (re-runs every rebuild) - Distinguish
onCreateCommand(once only) frompostCreateCommand(every rebuild)
Permission denied on files
- Container UID and host UID don't match (especially on Linux hosts)
- Set
remoteUserplusupdateRemoteUserUID:"remoteUser": "vscode", "updateRemoteUserUID": true
forwardPorts not working
- Does the in-container app bind
0.0.0.0or::(not127.0.0.1)? - Example: Next.js needs
next dev -H 0.0.0.0
Host Git config not picked up
.gitconfigauto-mount is optional — add it explicitly inmounts- Or rely on the
featuresgit which uses agitconfigcopy
IDE is slow with huge node_modules
- Exclude
node_modulesfrom VS Code's watcher (files.watcherExclude) - Or put
node_modulesin a container-only volume instead of a host mount:"mounts": [ "source=${localWorkspaceFolderBasename}-node-modules,target=/workspaces/${localWorkspaceFolderBasename}/node_modules,type=volume" ]
13. What's next
- Mac Docker — /mac/docker-setup (runtime comparison)
- Windows Docker WSL2 — /windows/docker-wsl2
- Remote development — /multi-os/remote-development — use a remote machine as your dev environment
- Dotfiles sync — /mac/dotfiles — apply dotfiles inside the dev container too via chezmoi
References
Changelog
- 2026-05-16: First draft. devcontainer.json essentials · base image picking · features · docker-compose integration · CI integration · six troubleshooting cases.