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.

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.

Comments