Docker Development Environments: Dev Containers, Compose and Codespaces
"It works on my machine" is the oldest and most persistent problem in software development. Developer A has Python 3.11, Developer B has 3.9. One has PostgreSQL 15 installed locally, the other uses SQLite for testing. The new hire spends two days setting up their environment because the README is outdated. Docker development environments solve this permanently: every developer gets the same OS, the same tools, the same database versions, with a single command.
This guide covers three approaches to Docker-based development environments: VS Code Dev Containers for a full IDE experience inside Docker, Docker Compose for orchestrating development services, and GitHub Codespaces for cloud-based development. We also cover the practical details that make or break the experience: hot reloading, database seeding, debugging, and environment parity with production.
VS Code Dev Containers
Dev Containers (formerly "Remote - Containers") let VS Code run entirely inside a Docker container. Your code, terminal, extensions, and debugging session all run in the container, while VS Code's UI runs on your host.
devcontainer.json Configuration
// .devcontainer/devcontainer.json
{
"name": "My App Dev Environment",
"dockerComposeFile": "../docker-compose.dev.yml",
"service": "app",
"workspaceFolder": "/workspace",
// Features add tools to the container
"features": {
"ghcr.io/devcontainers/features/go:1": { "version": "1.22" },
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
// VS Code settings inside the container
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"ms-azuretools.vscode-docker",
"eamodio.gitlens",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss"
],
"settings": {
"go.toolsManagement.autoUpdate": true,
"go.lintTool": "golangci-lint",
"editor.formatOnSave": true,
"terminal.integrated.defaultProfile.linux": "bash"
}
}
},
// Lifecycle scripts
"postCreateCommand": "go mod download && make setup",
"postStartCommand": "make dev-services",
"postAttachCommand": "echo 'Dev environment ready!'",
// Port forwarding
"forwardPorts": [8080, 5432, 6379],
"portsAttributes": {
"8080": { "label": "App", "onAutoForward": "notify" },
"5432": { "label": "PostgreSQL", "onAutoForward": "silent" },
"6379": { "label": "Redis", "onAutoForward": "silent" }
},
// Environment variables
"containerEnv": {
"DATABASE_URL": "postgres://dev:devpass@postgres:5432/devdb?sslmode=disable",
"REDIS_URL": "redis://redis:6379",
"ENV": "development"
},
// Run as non-root user
"remoteUser": "vscode"
}
Custom Dev Container Dockerfile
For projects that need specific tools beyond what features provide, use a custom Dockerfile:
# .devcontainer/Dockerfile
FROM mcr.microsoft.com/devcontainers/go:1.22
# Install project-specific tools
RUN apt-get update && apt-get install -y \
postgresql-client \
redis-tools \
protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
# Install Go tools
RUN go install github.com/a-h/templ/cmd/templ@latest \
&& go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest \
&& go install github.com/air-verse/air@latest
# Install Tailwind CSS standalone
RUN curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
-o /usr/local/bin/tailwindcss && chmod +x /usr/local/bin/tailwindcss
WORKDIR /workspace
// .devcontainer/devcontainer.json (using custom Dockerfile)
{
"name": "Custom Dev Environment",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind",
"workspaceFolder": "/workspace"
}
Docker Compose for Development
A development Compose file differs significantly from production. It needs source code mounting, hot reloading, debug ports, and relaxed resource constraints:
# docker-compose.dev.yml
services:
app:
build:
context: .
dockerfile: .devcontainer/Dockerfile
volumes:
- .:/workspace:cached
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
ports:
- "8080:8080" # Application
- "2345:2345" # Delve debugger
environment:
- DATABASE_URL=postgres://dev:devpass@postgres:5432/devdb?sslmode=disable
- REDIS_URL=redis://redis:6379
- ENV=development
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
command: air # Hot reloading with Air
postgres:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
- ./scripts/seed.sql:/docker-entrypoint-initdb.d/seed.sql:ro
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: devpass
POSTGRES_DB: devdb
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev -d devdb"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
volumes:
pgdata:
go-mod-cache:
go-build-cache:
:cached mount consistency flag significantly improves performance on macOS. Also, mount Go module and build caches as named volumes (go-mod-cache, go-build-cache) to persist downloaded dependencies and compiled packages between container restarts. Without these, every restart triggers a full download and recompile.
Hot Reloading
Hot reloading rebuilds and restarts your application automatically when source files change. For Go, Air is the standard tool:
# .air.toml
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = ["serve"]
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./cmd/myapp"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
exclude_file = []
exclude_regex = ["_test.go", "_templ.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "templ", "html", "css"]
kill_delay = "2s"
log = "build-errors.log"
send_interrupt = true
stop_on_error = true
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = true
For frontend assets with Tailwind CSS, run the watcher alongside Air:
# Makefile targets for development
.PHONY: dev
dev:
@echo "Starting development environment..."
docker compose -f docker-compose.dev.yml up -d postgres redis
@sleep 3
make dev-watch
.PHONY: dev-watch
dev-watch:
@# Run Air and Tailwind CSS watcher in parallel
@air & \
tailwindcss -i ./web/static/src/input.css -o ./web/static/css/style.css --watch & \
templ generate --watch & \
wait
Database Seeding
Development databases need sample data. Place SQL seed files in the init directory for automatic execution on first start:
-- scripts/seed.sql
-- This runs automatically on first container start
-- Create additional schemas
CREATE SCHEMA IF NOT EXISTS app;
-- Seed users
INSERT INTO users (id, email, name, role, created_at) VALUES
('550e8400-e29b-41d4-a716-446655440001', '[email protected]', 'Admin User', 'admin', NOW()),
('550e8400-e29b-41d4-a716-446655440002', '[email protected]', 'Developer', 'operator', NOW()),
('550e8400-e29b-41d4-a716-446655440003', '[email protected]', 'Viewer', 'viewer', NOW())
ON CONFLICT (id) DO NOTHING;
-- Seed sample data
INSERT INTO projects (name, owner_id, description) VALUES
('Demo Project', '550e8400-e29b-41d4-a716-446655440001', 'Sample project for development'),
('Test Project', '550e8400-e29b-41d4-a716-446655440002', 'Testing environment project')
ON CONFLICT DO NOTHING;
For more complex seeding, use a dedicated script:
#!/bin/bash
# scripts/seed-dev.sh - Reset and seed the development database
set -e
echo "Resetting development database..."
docker exec -i postgres psql -U dev -d devdb -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
echo "Running migrations..."
go run ./cmd/myapp migrate up
echo "Seeding data..."
docker exec -i postgres psql -U dev -d devdb < scripts/seed.sql
echo "Development database ready."
GitHub Codespaces
GitHub Codespaces provides cloud-hosted dev containers. Your .devcontainer/devcontainer.json works identically in Codespaces and local VS Code:
// .devcontainer/devcontainer.json additions for Codespaces
{
"hostRequirements": {
"cpus": 4,
"memory": "8gb",
"storage": "32gb"
},
// Prebuild configuration for faster startup
"updateContentCommand": "go mod download",
"postCreateCommand": "make setup && make migrate",
// Codespaces-specific secrets
"secrets": {
"API_KEY": {
"description": "API key for external service integration"
}
}
}
Enable prebuilds for near-instant Codespace creation:
# .github/workflows/codespaces-prebuild.yml
name: Codespaces Prebuild
on:
push:
branches: [main]
schedule:
- cron: '0 0 * * *' # Daily
jobs:
prebuild:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: github/codespaces-precache@v1
Debugging in Containers
For Go applications, use Delve for remote debugging inside Docker:
# Dockerfile.debug
FROM golang:1.22-alpine
RUN go install github.com/go-delve/delve/cmd/dlv@latest
WORKDIR /workspace
COPY . .
RUN go build -gcflags="all=-N -l" -o /app ./cmd/myapp
EXPOSE 8080 2345
CMD ["dlv", "--listen=:2345", "--headless=true", "--api-version=2", "--accept-multiclient", "exec", "/app"]
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Docker",
"type": "go",
"request": "attach",
"mode": "remote",
"remotePath": "/workspace",
"port": 2345,
"host": "127.0.0.1"
}
]
}
Environment Parity
The value of Docker development environments is environment parity: dev, test, and production use the same database versions, the same OS, and the same configuration. Here is how to achieve it:
| Aspect | Development | Production | Shared |
|---|---|---|---|
| Database version | postgres:16-alpine | postgres:16-alpine | Same image tag |
| Redis version | redis:7-alpine | redis:7-alpine | Same image tag |
| Application build | Source mount + Air | Multi-stage Docker build | Same Go version, same deps |
| Config | .env.development | .env.production (secrets) | Same config structure |
| Networking | Docker bridge | Overlay / host | Same service names |
latest tags in development Compose files if you pin specific versions in production. A developer running postgres:latest while production runs postgres:16 will eventually encounter version-specific behavior differences that cause production bugs. Pin the same version everywhere.
Maintaining environment parity becomes easier when you use container management platforms like usulnet. You can track which image versions are running in production and mirror those exact versions in your development Compose files, ensuring that "it works on my machine" also means "it will work in production."
Setup Checklist
- devcontainer.json committed to repo - New developers run one command to start
- Docker Compose with health checks - Services start in the correct order
- Named volumes for caches - Dependencies persist between restarts
- Hot reloading configured - Changes reflect immediately without manual rebuilds
- Database seeding automated - Dev database has useful sample data from first start
- Debugging configured - Breakpoints work through Docker without extra setup
- Version parity with production - Same database, same Redis, same OS base
- Documentation minimal - If it needs a README longer than 5 lines, it needs more automation