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:
- Dependency Hell: Different Python versions, conflicting system libraries, OS-specific packages
- Onboarding Friction: Hours or days setting up a new developer machine
- Environment Drift: Developer environments diverge over time, causing mysterious bugs
- Platform Inconsistency: macOS, Linux, and Windows require different setup steps
- 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¶
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¶
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¶
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:
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
.gitconfigand 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:
- Multi-service development: Database, Redis, message queues, etc.
- Service discovery: Services reference each other by name
- Network isolation: Private network for development services
- Volume management: Shared volumes between services
- 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 infinitykeeps 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¶
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¶
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¶
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¶
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:
External Networks¶
Theory: External networks exist independently of the compose stack. Create them once:
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:
- Idempotent: Can run multiple times without errors
- Fast: Use database constraints to skip existing data
- Comprehensive: Set up everything needed for development
- 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:
- Platform-agnostic: Detect OS and install appropriate dependencies
- Tool installation: Install required tools (uv, direnv)
- Environment creation: Create virtual environment with correct Python version
- Auto-activation: Setup direnv for automatic venv activation
- Dependency installation: Install all development dependencies
Common Patterns¶
Certificate Management¶
For HTTPS development, mount self-signed certificates:
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:
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:
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¶
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:
${localEnv:VAR_NAME} expands to the host's environment variable. Useful for user-specific paths.
Resource Management¶
Resource Limits¶
Prevent resource exhaustion:
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: View → Output → 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:
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
latesttags: 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:
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¶
- Docker Configuration: Deep dive into Dockerfile optimization
- Local Setup: Complete local development environment setup
- Django Settings: Multi-environment configuration patterns
- Testing Strategies: Test organization and execution