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.
I think Dev Containers are what makes "reproducible environment" actually mean something. Not because Docker is the only way to achieve consistency, but rather because devcontainer.json is a file the team shares through version control — which means it's the same definition for everyone, reviewed like code, and applied automatically when someone joins. Because the environment definition lives in the repo, "it works on my machine" becomes "it works on any machine that opens this container."
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.
Keep reading
- Mac ↔ Windows keyboard mapping — Karabiner + PowerToys Keyboard Manager
Smooth out the Cmd ↔ Ctrl divide between the two OSes. Karabiner-Elements (Mac) and PowerToys Keyboard Manager (Windows) setup plus five common remappings.
- Mac + Windows clipboard sync — Universal Clipboard · 1Clipboard · self-hosted
Three paths to sync text/images between Mac and Windows clipboards instantly. Free, paid, and self-hosted compared.
- File sync — P2P Mac ↔ Windows folders with Syncthing
Real-time folder sync between two machines without the cloud. Keep dotfiles / notes / projects identical anywhere.