5 min read
On this page

Dev Containers

"It works on my machine" is the most expensive sentence in software development. It means the bug is environmental, the fix is going to involve comparing toolchain versions across machines, and somebody is going to lose half a day before discovering that the problem is a different version of Node or a missing system library.

Dev containers solve this by defining the development environment as code. Every developer works inside the same container, with the same OS, the same tools, the same versions. The container definition lives in the repo, so the environment is versioned alongside the code. When dependencies change, the container changes. When a new developer joins, they open the project and the environment builds itself.

The Core Idea

A dev container is a Docker container configured for development, not deployment. It includes your code editor's language server, your linter, your debugger, your test runner -- everything you need to develop, not just everything you need to run the application.

Production container:          Dev container:
- Runtime only                 - Runtime + dev tools
- Minimal OS                   - Full toolchain
- No editor integration        - Language server, debugger
- No test frameworks           - Test runner, coverage tools
- Optimized for size           - Optimized for developer experience

The dev container specification (devcontainers.json) was originally created by Microsoft for VS Code but has become an open standard supported by multiple editors and cloud environments.

devcontainer.json

The configuration file lives at .devcontainer/devcontainer.json in the project root.

// .devcontainer/devcontainer.json
{
  "name": "My Project",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:20",

  "features": {
    "ghcr.io/devcontainers/features/github-cli:1": {},
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  },

  "forwardPorts": [3000, 5432],

  "postCreateCommand": "npm install && npm run db:migrate",

  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "bradlc.vscode-tailwindcss"
      ],
      "settings": {
        "editor.formatOnSave": true,
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      }
    }
  },

  "remoteUser": "node"
}

When a developer opens this project, their editor detects the devcontainer.json, builds or pulls the container image, installs the specified extensions inside the container, runs the post-create command, and forwards the specified ports. The developer is ready to code with zero manual setup.

Using a Custom Dockerfile

Pre-built images work for simple projects, but most real-world applications need additional tools or system libraries. Use a custom Dockerfile.

# .devcontainer/Dockerfile
FROM mcr.microsoft.com/devcontainers/typescript-node:20

# System dependencies
RUN apt-get update && apt-get install -y \
    postgresql-client \
    redis-tools \
    && rm -rf /var/lib/apt/lists/*

# Global npm tools
RUN npm install -g @nestjs/cli prisma

# Python (for scripts and tools)
RUN apt-get update && apt-get install -y python3 python3-pip \
    && rm -rf /var/lib/apt/lists/*

Reference it in devcontainer.json:

{
  "name": "My Project",
  "build": {
    "dockerfile": "Dockerfile",
    "context": ".."
  },
  "postCreateCommand": "npm install && npm run db:migrate && npm run db:seed"
}

Keep the Dockerfile focused on tools, not on application code. The application code is mounted into the container from the host filesystem. The container provides the environment; the host provides the code.

Docker Compose Integration

Most applications need more than just a development container. They need databases, caches, and other services. Dev containers integrate with Docker Compose to provide the full stack.

# .devcontainer/docker-compose.yml
services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile
    volumes:
      - ..:/workspace:cached
      - node_modules:/workspace/node_modules
    command: sleep infinity
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: myapp_dev
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: pg_isready -U dev
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    healthcheck:
      test: redis-cli ping
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  node_modules:
  postgres_data:
// .devcontainer/devcontainer.json
{
  "name": "My Project",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "forwardPorts": [3000, 5432],
  "postCreateCommand": "npm install && npm run db:migrate && npm run db:seed"
}

The node_modules named volume is a performance trick. Mounting node_modules from the host to the container (especially on macOS) is painfully slow because of filesystem translation overhead. A named volume keeps node_modules inside the container's native filesystem.

Performance Considerations

The biggest complaint about dev containers is performance. File system operations are slow when Docker translates between the host and container filesystems, particularly on macOS.

Mitigation strategies:

1. Named volumes for heavy directories (node_modules, .venv, target/)
2. Use ':cached' mount flag for the workspace volume
3. Use Docker Desktop's VirtioFS file sharing (macOS)
4. Keep the container image small (faster builds)
5. Use a prebuild to cache the container image

For large projects where file system performance is critical, consider remote dev containers (discussed below) where both the code and the container run on a remote machine with native filesystem performance.

Prebuilds

Building a dev container from scratch takes time -- installing packages, compiling tools, pulling images. Prebuilds create the container image in advance (usually in CI) so developers get a ready-to-use environment instantly.

# GitHub Actions prebuild
name: Prebuild Dev Container
on:
  push:
    branches: [main]
    paths:
      - '.devcontainer/**'
      - 'package-lock.json'
      - 'Dockerfile'

jobs:
  prebuild:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: devcontainers/ci@v0.3
        with:
          imageName: ghcr.io/myorg/myproject-devcontainer
          cacheFrom: ghcr.io/myorg/myproject-devcontainer
          push: always

Reference the prebuilt image in devcontainer.json:

{
  "image": "ghcr.io/myorg/myproject-devcontainer:latest"
}

Now when a developer opens the project, the container is pulled (fast) instead of built (slow). The prebuild runs in CI whenever the container definition changes, so the image is always current.

Remote Development

Dev containers do not have to run locally. Remote development moves the container to a cloud machine, giving every developer a powerful, consistent environment regardless of their local hardware.

Remote dev options:

GitHub Codespaces:     Dev containers in the cloud, launched from GitHub
Gitpod:                Similar to Codespaces, supports multiple providers
Coder:                 Self-hosted remote dev environments
VS Code Remote SSH:    Connect to any machine with SSH
JetBrains Gateway:     Remote dev for JetBrains IDEs

Remote development is particularly valuable for:

- Large codebases where builds are slow on laptops
- Teams with varied hardware (some on powerful desktops, some on thin laptops)
- Onboarding: a new hire opens a Codespace and is coding in minutes
- Contractors who should not clone the repo to their personal machine
- Projects that need GPUs or large amounts of RAM

The latency tradeoff is real. Typing in a remote container over a network has perceptible latency compared to local development. For most work this is acceptable, but for latency-sensitive tasks (gaming, real-time audio), local development is still better.

When to Use Dev Containers

Dev containers are not always the right choice. They add complexity, and for simple projects, the overhead is not worth it.

Good fit:
- Complex dependency chains (native libraries, system packages)
- Team with mixed operating systems
- Frequent onboarding (new hires, contractors)
- Projects that need specific tool versions across the team
- Monorepos with multiple language toolchains

Not needed:
- Solo projects where you control the environment
- Simple projects with few dependencies
- Teams already aligned on tooling and OS
- Projects where Docker performance overhead is unacceptable

The decision point is usually the onboarding experience. If setting up the project takes more than 30 minutes, dev containers will pay for themselves quickly. If setup is already fast and reliable, dev containers add complexity without proportional benefit.

Common Pitfalls

  • Slow container builds without prebuilds. A 10-minute container build on first open is a terrible experience. Use prebuilds or at minimum a pre-built base image.
  • Mounting everything from host. Mounting the entire project directory including node_modules or .venv from the host is slow. Use named volumes for dependency directories.
  • Not testing the dev container in CI. The devcontainer.json can break just like any other config file. Build it in CI to catch issues before developers hit them.
  • Divergence between dev container and production. If you develop in Ubuntu but deploy on Alpine, you will hit surprises. Use a base image that matches or closely resembles production.
  • Ignoring editor-specific configuration. A dev container without editor extensions configured is just a Docker container. Include the extensions and settings that make the development experience complete.
  • Forgetting the postCreateCommand. The container has the tools, but the project is not set up. Dependencies are not installed, the database is not migrated, and seed data is not loaded. postCreateCommand should handle all of this.

Key Takeaways

  • Dev containers define the development environment as code, eliminating "it works on my machine" by ensuring every developer uses the same toolchain.
  • The devcontainer.json spec is supported by VS Code, JetBrains, GitHub Codespaces, and other tools. It is an open standard, not vendor lock-in.
  • Use Docker Compose integration for projects that need databases and other services alongside the dev container.
  • Named volumes for dependency directories (node_modules, .venv) solve the file system performance problem on macOS.
  • Prebuilds create the container image in CI so developers get a ready environment in seconds instead of minutes.
  • Remote development via Codespaces or similar tools provides powerful, consistent environments for teams with varied hardware.
  • Dev containers pay for themselves when onboarding is slow or dependency management is complex. For simple projects, they may add unnecessary overhead.