devAlice
← Multi-OS

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

  1. Install a Docker runtime (Mac: OrbStack/Docker Desktop, Win: Docker Desktop/WSL Docker, Linux: docker)
  2. VS Code + the Dev Containers extension (ms-vscode-remote.remote-containers)
  3. Drop one .devcontainer/devcontainer.json at the project root
  4. Cmd/Ctrl+Shift+PDev Containers: Reopen in Container — IDE relaunches inside the container
  5. Teammate: clone → run that command once → identical environment

Prerequisites

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:

FieldRole
imageDocker base image (Node / Python / Go / Rust, etc.)
featuresAdd-on tools (git, gh, docker-in-docker, awscli — pickable from a catalog)
postCreateCommandRuns once after container creation (install deps, etc.)
customizations.vscode.extensionsVS Code extensions auto-installed inside the container
forwardPortsPorts exposed to the host
remoteUserUser 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/node

Same Linux environment regardless of the host OS.


4. Picking a base image

The image: in devcontainer.json. Official catalog:

Language / stackImage
Node.jsmcr.microsoft.com/devcontainers/javascript-node:1-{18,20,22}-bookworm
Pythonmcr.microsoft.com/devcontainers/python:1-{3.11,3.12,3.13}-bookworm
Gomcr.microsoft.com/devcontainers/go:1-{1.22,1.23}
Rustmcr.microsoft.com/devcontainers/rust:1-bookworm
Javamcr.microsoft.com/devcontainers/java:1-{17,21}-bookworm
.NETmcr.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 test

GitHub Actions:

# .github/workflows/test.yml
- name: Test in dev container
  uses: devcontainers/ci@v0.3
  with:
    runCmd: npm test

CI 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 features

All 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 ps to 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. postCreateCommand is not cached (re-runs every rebuild)
  • Distinguish onCreateCommand (once only) from postCreateCommand (every rebuild)

Permission denied on files

  • Container UID and host UID don't match (especially on Linux hosts)
  • Set remoteUser plus updateRemoteUserUID:
    "remoteUser": "vscode",
    "updateRemoteUserUID": true

forwardPorts not working

  • Does the in-container app bind 0.0.0.0 or :: (not 127.0.0.1)?
  • Example: Next.js needs next dev -H 0.0.0.0

Host Git config not picked up

  • .gitconfig auto-mount is optional — add it explicitly in mounts
  • Or rely on the features git which uses a gitconfig copy

IDE is slow with huge node_modules

  • Exclude node_modules from VS Code's watcher (files.watcherExclude)
  • Or put node_modules in 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


References

Changelog

  • 2026-05-16: First draft. devcontainer.json essentials · base image picking · features · docker-compose integration · CI integration · six troubleshooting cases.

Keep reading