Skip to content

Development Containers (Devcontainers)

Overview

Development containers (devcontainers) provide a complete, reproducible development environment using Docker containers. They solve the "works on my machine" problem by ensuring every team member works in an identical environment, regardless of their host operating system.

Why Devcontainers?

Key Benefits

  • Consistent environments across all developers
  • Fast onboarding - new developers productive in minutes
  • Isolated dependencies - no conflicts with host system
  • Version-controlled environment - devcontainer configuration is code
  • Pre-configured tools - linters, formatters, extensions ready to use
  • Cloud development ready - works in VS Code, GitHub Codespaces, etc.

Problems Solved

Traditional development environments face several challenges:

  1. Dependency Hell: Different Python versions, conflicting system libraries, OS-specific packages
  2. Onboarding Friction: Hours or days setting up a new developer machine
  3. Environment Drift: Developer environments diverge over time, causing mysterious bugs
  4. Platform Inconsistency: macOS, Linux, and Windows require different setup steps
  5. Tool Version Conflicts: Multiple projects requiring different tool versions

Devcontainers solve these by containerizing the entire development environment.

Architecture

Component Hierarchy

graph TB
    A[VS Code Host] --> B[Devcontainer]
    B --> C[Docker Compose]
    C --> D[App Container]
    C --> E[Database Container]
    C --> F[Redis Container]
    C --> G[LocalStack Container]
    D --> H[Python Environment]
    D --> I[System Dependencies]
    D --> J[Development Tools]
    K[Source Code] -.->|Bind Mount| D
    L[Extensions] -.->|Remote Install| D

File Structure

.devcontainer/
├── devcontainer.json          # Main configuration
├── docker-compose.yml         # Service definitions
├── features/                  # Custom features
│   └── claude-code/           # AI tools
│       ├── devcontainer-feature.json
│       └── install.sh
└── production/                # Production-like variant
    └── devcontainer.json

devcontainer.json Configuration

Minimal Configuration

The devcontainer.json file is the heart of the devcontainer setup:

{
  "name": "Project DevContainer",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/app",
  "shutdownAction": "stopCompose",
  "remoteUser": "root"
}

Theory: This configuration tells VS Code:

  • name: Human-readable identifier for the container
  • dockerComposeFile: Use Docker Compose for multi-service orchestration
  • service: Which service in the compose file is the primary development container
  • workspaceFolder: Where source code lives inside the container
  • shutdownAction: What to do when closing VS Code (stopCompose vs none)
  • remoteUser: Which user runs commands inside the container

Advanced Configuration

Production-grade devcontainers include additional capabilities:

{
  "name": "Zenith DevContainer (AI - Claude Code)",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "runServices": ["app"],
  "workspaceFolder": "/app",
  "postCreateCommand": "bash /app/init_dev_in_devcontainer.sh",
  "features": {
    "./features/claude-code": {}
  },
  "mounts": [
    "source=${localEnv:HOME}/certs,target=/certs,type=bind",
    "source=${localEnv:HOME}/zenithbashhistory,target=/commandhistory,type=bind"
  ],
  "shutdownAction": "stopCompose",
  "remoteUser": "root"
}

Key Elements Explained:

runServices

"runServices": ["app"]

Controls which Docker Compose services start when the devcontainer launches. Useful when you have many services defined but only want specific ones running during development.

Theory: This prevents resource waste by only starting essential services. Other services can be started manually when needed.

postCreateCommand

"postCreateCommand": "bash /app/init_dev_in_devcontainer.sh"

Executes after the container is created but before VS Code connects. This is where you run initialization scripts:

  • Database migrations
  • Test database setup
  • Cache table creation
  • Superuser creation
  • Initial data loading

Theory: Separates container building (Dockerfile) from development environment initialization. The container image remains generic; initialization is project-specific.

Features

"features": {
  "./features/claude-code": {}
}

Devcontainer features are reusable, self-contained units of installation code. They can be:

  • Local (./ path - in your repo)
  • Official Microsoft features (ghcr.io/devcontainers/features/...)
  • Community features (ghcr.io/username/features/...)

Theory: Features enable composition of capabilities without bloating the base Dockerfile. They're installed at container creation time and can accept configuration options.

Example custom feature structure:

features/claude-code/
├── devcontainer-feature.json    # Metadata
└── install.sh                   # Installation script

Mounts

"mounts": [
  "source=${localEnv:HOME}/certs,target=/certs,type=bind",
  "source=${localEnv:HOME}/zenithbashhistory,target=/commandhistory,type=bind"
]

Mounts bind host directories into the container. Common use cases:

  • SSL certificates: Self-signed certs for HTTPS development
  • Shell history: Persist bash/zsh history across container rebuilds
  • Git configuration: Share .gitconfig and credentials
  • SSH keys: Access private repositories

Theory: Mounts provide persistence and host integration without rebuilding the container. Use them for user-specific data that shouldn't be in the Docker image.

Security Note: Never mount sensitive credentials directly. Use SSH agent forwarding or credential helpers instead.

Docker Compose Integration

Why Docker Compose?

Devcontainers use Docker Compose to orchestrate multiple services:

  1. Multi-service development: Database, Redis, message queues, etc.
  2. Service discovery: Services reference each other by name
  3. Network isolation: Private network for development services
  4. Volume management: Shared volumes between services
  5. Environment variable management: Centralized configuration

Basic Structure

services:
  app:
    build:
      context: ..
      dockerfile: docker/Dockerfile.app
      target: development
      args:
        PYTHON_VERSION: "3.13.5"
    command: sleep infinity
    volumes:
      - type: bind
        source: ..
        target: /app
    networks:
      - dev-network

networks:
  dev-network:
    external: true

Theory: The app service definition:

  • build.context: Build context is the parent directory (entire project)
  • build.dockerfile: Dockerfile location relative to context
  • build.target: Multi-stage build target (development vs production)
  • build.args: Build-time variables (Python version, etc.)
  • command: sleep infinity keeps container running (VS Code attaches to it)
  • volumes: Bind mount source code for live editing
  • networks: Connect to external network shared with other services

Complete Multi-Service Setup

Production devcontainers typically include supporting services:

x-app-base: &app-base
  build:
    context: ..
    dockerfile: docker/Dockerfile.app
    target: development
    args:
      PYTHON_VERSION: "3.13.5"
  platform: linux/amd64
  command: sleep infinity
  volumes:
    - type: bind
      source: ..
      target: /app
    - claude-config:/root/.claude
  networks:
    - dev-network

services:
  # Development container (with AI tools)
  app:
    <<: *app-base
    environment:
      - AWS_DEFAULT_REGION=us-east-1
      - AWS_ENDPOINT_URL=http://localstack:4566
    container_name: project-ai-dev
    ports:
      - "443:443"
    env_file:
      - ../.env.local

  # Production-like container (no AI tools)
  app-prod:
    <<: *app-base
    ports:
      - "443:443"
    container_name: project-prod-dev
    environment:
      - DJANGO_SETTINGS_MODULE=myapp.settings.production-local

  # Supporting services
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: myapp
    volumes:
      - db-data:/var/lib/mysql
    networks:
      - dev-network

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    networks:
      - dev-network

  localstack:
    image: localstack/localstack:latest
    ports:
      - "4566:4566"
    environment:
      - SERVICES=ssm,s3,sqs,ses
      - DEBUG=1
    volumes:
      - localstack-data:/var/lib/localstack
    networks:
      - dev-network

volumes:
  claude-config:
  db-data:
  localstack-data:

networks:
  dev-network:
    external: true

Key Patterns Explained:

YAML Anchors and Aliases

x-app-base: &app-base
  build: ...

services:
  app:
    <<: *app-base

Theory: Anchors (&app-base) define reusable configuration blocks. Aliases (*app-base) reference them. The merge key (<<:) incorporates the anchor's content. This eliminates duplication when multiple services share configuration.

Platform Specification

platform: linux/amd64

Theory: Ensures consistent behavior on ARM-based Macs (M1/M2/M3). Specifies the target architecture explicitly, preventing subtle bugs from architecture differences.

Named Volumes vs Bind Mounts

volumes:
  - type: bind
    source: ..
    target: /app
  - claude-config:/root/.claude

Theory:

  • Bind mounts (source: ..): Map host directories for live editing
  • Named volumes (claude-config:): Docker-managed storage for persistence

Bind mounts are for source code (needs live updates). Named volumes are for data that persists across rebuilds but doesn't need host access.

Service Dependencies

depends_on:
  db:
    condition: service_healthy

Theory: Controls startup order. The condition ensures the database is fully ready before starting the app. Requires the dependency service to define a health check:

db:
  healthcheck:
    test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
    timeout: 5s
    retries: 10

External Networks

networks:
  dev-network:
    external: true

Theory: External networks exist independently of the compose stack. Create them once:

docker network create dev-network

Multiple compose stacks can connect to the same network, enabling service discovery across projects. Useful for shared infrastructure services (databases, message brokers).

VS Code Integration

Extensions

Devcontainers can automatically install VS Code extensions:

{
  "customizations": {
    "vscode": {
      "extensions": [
        "ms-python.python",
        "ms-python.vscode-pylance",
        "ms-python.debugpy",
        "charliermarsh.ruff",
        "ms-azuretools.vscode-docker",
        "eamodio.gitlens",
        "GitHub.copilot",
        "tamasfe.even-better-toml",
        "redhat.vscode-yaml"
      ],
      "settings": {
        "python.defaultInterpreterPath": "/usr/local/bin/python",
        "python.linting.enabled": true,
        "python.linting.ruffEnabled": true,
        "python.formatting.provider": "none",
        "editor.formatOnSave": true,
        "editor.codeActionsOnSave": {
          "source.organizeImports": true,
          "source.fixAll": true
        },
        "[python]": {
          "editor.defaultFormatter": "charliermarsh.ruff"
        }
      }
    }
  }
}

Theory:

  • extensions: Install automatically when container is created
  • settings: Override user settings for this workspace
  • python.defaultInterpreterPath: Point to container's Python, not host's

This ensures consistent tooling across all developers without manual configuration.

Debugging Configuration

{
  "customizations": {
    "vscode": {
      "settings": {
        "python.defaultInterpreterPath": "/usr/local/bin/python"
      }
    }
  }
}

Combine with .vscode/launch.json for debugging:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Django Runserver",
      "type": "python",
      "request": "launch",
      "program": "${workspaceFolder}/manage.py",
      "args": ["runserver", "0.0.0.0:8000"],
      "django": true,
      "env": {
        "DJANGO_SETTINGS_MODULE": "myapp.settings.development"
      }
    },
    {
      "name": "Pytest Current File",
      "type": "python",
      "request": "launch",
      "module": "pytest",
      "args": ["${file}", "-v"],
      "console": "integratedTerminal"
    }
  ]
}

Theory: Debugging configurations use the container's Python interpreter. Environment variables set container-specific paths and settings.

Initialization Scripts

Post-Create Command Pattern

The postCreateCommand runs initialization tasks:

#!/usr/bin/env bash
# init_dev_in_devcontainer.sh

set -e  # Exit on error

echo "🚀 Initializing development environment..."

# Run migrations
DJANGO_SETTINGS_MODULE='myapp.settings.development' \
  python manage.py migrate

# Create test database
DJANGO_SETTINGS_MODULE='myapp.settings.development' \
  python manage.py migrate --database=test_db

# Create cache tables
DJANGO_SETTINGS_MODULE='myapp.settings.development' \
  python manage.py createcachetable

# Create superuser
DJANGO_SETTINGS_MODULE='myapp.settings.development' \
  python manage.py createsu

# Create permission groups
DJANGO_SETTINGS_MODULE='myapp.settings.development' \
  python manage.py create_groups

# Generate API token for superuser
DJANGO_SETTINGS_MODULE='myapp.settings.development' \
  python manage.py drf_create_token admin

echo "✅ Development environment ready!"

Theory: Initialization scripts:

  1. Idempotent: Can run multiple times without errors
  2. Fast: Use database constraints to skip existing data
  3. Comprehensive: Set up everything needed for development
  4. Environment-specific: Use development settings, not production

Bootstrap Script Pattern

For environments outside devcontainers (local development), bootstrap scripts handle broader setup:

#!/bin/bash
# bootstrap_venv.sh

set -e
export UV_VENV_CLEAR=1  # Clear existing venvs

echo "Starting development environment setup..."

# Detect OS and install dependencies
if [[ "$OSTYPE" == "darwin"* ]]; then
    brew install openssl readline sqlite3 xz zlib
elif [[ "$OSTYPE" == "linux"* ]]; then
    sudo apt update
    sudo apt install -y build-essential libssl-dev zlib1g-dev \
        libbz2-dev libreadline-dev libsqlite3-dev wget curl
fi

# Install uv if not present
if ! command -v uv &> /dev/null; then
    curl -LsSf https://astral.sh/uv/install.sh | sh
fi

# Create and activate virtual environment
uv python install 3.13.5
uv venv venv-myapp-3.13.5-dev --python 3.13.5
source venv-myapp-3.13.5-dev/bin/activate

# Install dependencies
uv pip install -r requirements/requirements-dev.txt

# Setup direnv for auto-activation
cat > .envrc << 'EOF'
source venv-myapp-3.13.5-dev/bin/activate
export VIRTUAL_ENV_PROMPT="(myapp-dev) "
EOF

direnv allow

echo "✅ Setup completed!"

Theory: Bootstrap scripts:

  1. Platform-agnostic: Detect OS and install appropriate dependencies
  2. Tool installation: Install required tools (uv, direnv)
  3. Environment creation: Create virtual environment with correct Python version
  4. Auto-activation: Setup direnv for automatic venv activation
  5. Dependency installation: Install all development dependencies

Common Patterns

Certificate Management

For HTTPS development, mount self-signed certificates:

{
  "mounts": [
    "source=${localEnv:HOME}/certs,target=/certs,type=bind"
  ]
}

Generate certificates with a just command:

# justfile
setup-certificates:
    openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
        -keyout ~/certs/selfsigned.key \
        -out ~/certs/selfsigned.crt \
        -subj "/CN=localhost" \
        -addext "subjectAltName = DNS:localhost,DNS:*.localhost"

Theory: Self-signed certificates enable HTTPS development without relying on external services. Wildcard SANs support subdomain-based multi-tenancy.

Shell History Persistence

Preserve command history across container rebuilds:

{
  "mounts": [
    "source=${localEnv:HOME}/.bash_history_project,target=/commandhistory,type=bind"
  ]
}

Configure in Dockerfile:

RUN mkdir /commandhistory && \
    cat <<'EOF' >> /root/.bashrc
export PROMPT_COMMAND='history -a'
export HISTFILE=/commandhistory/.bash_history
EOF

Theory: Command history is valuable for recalling complex commands. Persisting it across rebuilds improves developer experience. Separate history files per project prevent cross-contamination.

Git Configuration

Share git config and credentials:

{
  "mounts": [
    "source=${localEnv:HOME}/.gitconfig,target=/root/.gitconfig,type=bind,readonly"
  ]
}

For SSH keys, use VS Code's built-in SSH agent forwarding instead of mounting keys directly (more secure).

Multiple Devcontainer Variants

Support different workflows with multiple configurations:

.devcontainer/
├── devcontainer.json           # Primary (with AI tools)
└── production/
    └── devcontainer.json       # Production-like (no AI)

Switch between them in VS Code: Dev Containers: Open Folder in Container → select variant.

Theory: Different workflows need different tools. AI-assisted development benefits from additional tools (Claude Code, Copilot), while production debugging needs a minimal, production-like environment.

Environment Variables

Loading Environment Files

services:
  app:
    env_file:
      - ../.env.local
    environment:
      - AWS_ENDPOINT_URL=http://localstack:4566

Theory:

  • env_file: Load variables from file (many variables)
  • environment: Define inline (few variables, overrides)

Priority: environment > env_file > Dockerfile ENV

Dynamic Environment Variables

Reference host environment variables:

{
  "mounts": [
    "source=${localEnv:HOME}/certs,target=/certs,type=bind"
  ]
}

${localEnv:VAR_NAME} expands to the host's environment variable. Useful for user-specific paths.

Resource Management

Resource Limits

Prevent resource exhaustion:

services:
  app:
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 4G
        reservations:
          cpus: '1'
          memory: 2G

Theory: Limits prevent container from consuming all host resources. Reservations guarantee minimum resources. Use for services that might grow unexpectedly (databases, build processes).

Build Cache Optimization

Use BuildKit cache mounts:

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && apt-get install -y ...

Theory: Cache mounts persist package manager caches across builds, dramatically speeding up rebuilds. The sharing=locked option enables safe concurrent access.

Troubleshooting

Common Issues

Container Won't Start

Symptom: VS Code reports "Container failed to start"

Diagnosis:

# View container logs
docker logs <container-name>

# Check compose services
docker compose -f .devcontainer/docker-compose.yml ps -a

# Validate compose file
docker compose -f .devcontainer/docker-compose.yml config

Common Causes: - Port conflicts (another service using the port) - Invalid Docker Compose syntax - Missing environment variables - Build failures

postCreateCommand Fails

Symptom: Container starts but VS Code reports initialization failure

Diagnosis:

# Run command manually inside container
docker exec -it <container-name> bash
bash /app/init_dev_in_devcontainer.sh

Common Causes: - Database not ready yet (add wait logic) - Missing environment variables - File permission issues - Migration conflicts

Extensions Don't Install

Symptom: Extensions listed in devcontainer.json don't appear

Diagnosis: 1. Check VS Code logs: ViewOutput → select "Dev Containers" 2. Verify extension IDs are correct 3. Check for platform compatibility (some extensions only work on Linux)

Solution: Manually install once, VS Code will remember for rebuilds.

Slow Performance

Symptom: File operations are slow, especially on macOS

Diagnosis: Check volume type:

volumes:
  - type: bind
    source: ..
    target: /app
    consistency: cached  # macOS optimization

Solutions: - Use :cached or :delegated consistency modes on macOS - Move node_modules to named volume - Use Docker Desktop with VirtioFS (Preferences → Experimental Features)

Best Practices

Do's

Recommended Practices

  • Version control everything: .devcontainer/ directory belongs in git
  • Keep it fast: Optimize Dockerfile layer caching
  • Document prerequisites: List required tools in README
  • Test initialization: Regularly rebuild from scratch
  • Use multi-stage builds: Separate development and production
  • Leverage features: Reuse common capabilities via devcontainer features
  • Mount selectively: Only mount what needs live updates
  • External networks: Share services across projects efficiently

Don'ts

Avoid These Patterns

  • Don't mount secrets: Use environment variables or secret management
  • Don't install tools at runtime: Put them in Dockerfile
  • Don't mix concerns: Keep container building and initialization separate
  • Don't skip health checks: Always define health checks for services
  • Don't use latest tags: Pin specific versions for reproducibility
  • Don't store state in container: Use volumes or databases
  • Don't over-customize: Balance team standardization with individual preferences

Advanced Topics

Custom Features

Create reusable features for common tooling:

{
  "id": "ruff-tool",
  "version": "1.0.0",
  "name": "Ruff Python Linter",
  "description": "Installs Ruff for Python linting and formatting",
  "options": {
    "version": {
      "type": "string",
      "default": "latest"
    }
  }
}

With installation script:

#!/bin/bash
# install.sh

VERSION="${VERSION:-latest}"

if [ "$VERSION" = "latest" ]; then
    pip install ruff
else
    pip install ruff==$VERSION
fi

Theory: Features encapsulate installation logic, making them reusable across projects. Options enable customization without duplicating code.

Lifecycle Scripts

Advanced devcontainers support multiple lifecycle hooks:

  • postCreateCommand: After container is created (once)
  • postStartCommand: Each time container starts
  • postAttachCommand: Each time VS Code attaches
{
  "postCreateCommand": "bash init.sh",
  "postStartCommand": "python manage.py migrate",
  "postAttachCommand": "echo 'Welcome back!'"
}

Theory:

  • postCreateCommand: Heavy initialization (migrations, data loading)
  • postStartCommand: Light checks (service health, migrations)
  • postAttachCommand: User feedback, status display

Remote Environment Variables

Inject secrets at runtime without storing in repository:

{
  "remoteEnv": {
    "DATABASE_URL": "${localEnv:DATABASE_URL}",
    "API_KEY": "${localEnv:API_KEY}"
  }
}

Theory: remoteEnv passes host environment variables into the container. Developers set these on their host machine; they never appear in version control.

Integration with CI/CD

Devcontainers aren't just for local development. Use the same container for CI:

# .github/workflows/test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/company/project-dev:latest
    steps:
      - uses: actions/checkout@v4
      - run: pytest

Theory: Using the devcontainer image in CI ensures perfect environment parity between development and testing. Build and push the devcontainer image as part of your workflow.

Next Steps