Production-ready Docker Compose patterns for multi-service applications.
services:
web:
build:
context: .
dockerfile: Dockerfile
target: production # Multi-stage build target
args:
APP_VERSION: "1.2.3" # Build-time args
image: myapp:latest # Tag the built image
container_name: myapp-web # Fixed container name
restart: unless-stopped
services:
db:
image: postgres:16-alpine
restart: unless-stopped
services:
worker:
image: myapp-worker:latest
deploy:
resources:
limits:
cpus: "2.0"
memory: 512M
reservations:
cpus: "0.5"
memory: 128M
services:
web:
environment:
NODE_ENV: production
DATABASE_URL: postgres://user:pass@db:5432/myapp
REDIS_URL: redis://cache:6379
services:
web:
env_file:
- .env # Default variables
- .env.production # Override for production
# Uses host environment variables or .env file in project root
services:
web:
image: myapp:${APP_VERSION:-latest}
environment:
DB_HOST: ${DB_HOST:?DB_HOST must be set} # Fail if not set
LOG_LEVEL: ${LOG_LEVEL:-info} # Default to "info"
docker compose run -e or docker compose exec -eenvironment: in compose file--env-file CLI flagenv_file: in compose file.env file in project directoryservices:
db:
image: postgres:16-alpine
volumes:
- db-data:/var/lib/postgresql/data # Docker-managed volume
volumes:
db-data:
driver: local
services:
web:
volumes:
- ./src:/app/src:cached # Source code (cached for macOS perf)
- ./config:/app/config:ro # Config files (read-only)
services:
web:
tmpfs:
- /tmp # Writable temp directory
- /app/cache:size=100M # Capped in-memory cache
read_only: true # Read-only root filesystem
services:
web:
volumes:
- ./src:/app/src # Override source
- /app/node_modules # But keep container's node_modules
services:
web:
networks:
- frontend
- backend
api:
networks:
- backend
db:
networks:
- backend # db is NOT accessible from frontend
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # No external access
Services on the same network can reach each other by service name:
services:
web:
environment:
API_URL: http://api:3000 # "api" resolves to the api container
DB_HOST: db # "db" resolves to the db container
api:
# ...
db:
# ...
services:
web:
ports:
- "8080:3000" # host:container
- "127.0.0.1:9090:9090" # Bind to localhost only
- "3000" # Random host port -> container 3000
services:
dns:
networks:
backend:
ipv4_address: 172.20.0.10
networks:
backend:
ipam:
config:
- subnet: 172.20.0.0/24
services:
web:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s # Grace period for startup
services:
web:
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 3000 || exit 1"]
interval: 15s
timeout: 3s
retries: 3
services:
postgres:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
mysql:
image: mysql:8
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
services:
web:
depends_on:
db:
condition: service_healthy # Wait for db to pass healthcheck
cache:
condition: service_healthy
migrations:
condition: service_completed_successfully # Run-once service
migrations:
build: .
command: ["python", "manage.py", "migrate"]
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
Important: Without condition: service_healthy, depends_on only waits for the container to start, not for the service inside to be ready.
Docker Compose automatically merges docker-compose.yml with docker-compose.override.yml.
services:
web:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: production
db:
image: postgres:16-alpine
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
# Automatically merged with docker-compose.yml
services:
web:
build:
target: development
volumes:
- ./src:/app/src # Live reload
- /app/node_modules
environment:
NODE_ENV: development
DEBUG: "true"
ports:
- "9229:9229" # Node debugger
db:
ports:
- "5432:5432" # Expose DB port for local tools
environment:
POSTGRES_PASSWORD: devpass
services:
web:
build:
target: production
restart: always
deploy:
replicas: 2
resources:
limits:
memory: 512M
db:
restart: always
# No port exposure in production
# Development (auto-merges override.yml)
docker compose up
# Production (explicit file selection)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Testing
docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm test
Profiles let you define services that only start when explicitly requested.
services:
web:
build: .
ports:
- "3000:3000"
# No profile = always starts
db:
image: postgres:16-alpine
# No profile = always starts
adminer:
image: adminer
ports:
- "8080:8080"
profiles:
- debug # Only starts with --profile debug
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025"
- "8025:8025"
profiles:
- debug # Only starts with --profile debug
prometheus:
image: prom/prometheus
profiles:
- monitoring # Only starts with --profile monitoring
grafana:
image: grafana/grafana
profiles:
- monitoring
# Start core services only
docker compose up
# Start with debug tools
docker compose --profile debug up
# Start with monitoring
docker compose --profile monitoring up
# Start with everything
docker compose --profile debug --profile monitoring up
Live reload for development without bind mounts (Compose 2.22+).
services:
web:
build:
context: .
target: development
develop:
watch:
# Sync source files -> restart not needed (hot reload)
- action: sync
path: ./src
target: /app/src
# Rebuild when dependencies change
- action: rebuild
path: ./package.json
# Sync + restart when config changes
- action: sync+restart
path: ./config
target: /app/config
# Ignore patterns
ignore:
- "**/*.test.ts"
- "**/node_modules"
# Start with file watching
docker compose watch
# Or alongside up
docker compose up --watch
| Action | Behavior | Use Case |
|---|---|---|
sync |
Copy changed files into container | Source code with HMR/hot reload |
rebuild |
Rebuild and recreate the container | Dependency changes (package.json) |
sync+restart |
Copy files then restart container | Config files, non-HMR code |
# docker-compose.yml (development-focused)
services:
web:
build:
context: .
target: development
volumes:
- ./src:/app/src
ports:
- "3000:3000"
- "9229:9229" # Debugger
environment:
NODE_ENV: development
command: ["npm", "run", "dev"]
db:
image: postgres:16-alpine
ports:
- "5432:5432" # Exposed for local access
environment:
POSTGRES_PASSWORD: devpass
POSTGRES_DB: myapp_dev
volumes:
- db-data:/var/lib/postgresql/data
- ./db/seed.sql:/docker-entrypoint-initdb.d/seed.sql
volumes:
db-data:
# docker-compose.prod.yml
services:
web:
image: myregistry/myapp:${VERSION} # Pre-built image
restart: always
read_only: true
tmpfs:
- /tmp
ports:
- "3000:3000"
environment:
NODE_ENV: production
deploy:
replicas: 2
resources:
limits:
memory: 512M
cpus: "1.0"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
db:
image: postgres:16-alpine
restart: always
# NO port exposure
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db-data:/var/lib/postgresql/data
secrets:
db_password:
file: ./secrets/db_password.txt
volumes:
db-data:
Web app + API + database + cache + worker + reverse proxy.
services:
# ---- Reverse Proxy ----
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
depends_on:
web:
condition: service_healthy
networks:
- frontend
restart: unless-stopped
# ---- Web Application ----
web:
build:
context: .
target: production
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/myapp
REDIS_URL: redis://cache:6379
API_URL: http://api:4000
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
networks:
- frontend
- backend
restart: unless-stopped
# ---- API Service ----
api:
build:
context: ./api
target: production
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/myapp
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
networks:
- backend
restart: unless-stopped
# ---- Background Worker ----
worker:
build:
context: .
target: production
command: ["node", "dist/worker.js"]
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/myapp
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
networks:
- backend
restart: unless-stopped
deploy:
replicas: 2
resources:
limits:
memory: 256M
# ---- Database ----
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: myapp
volumes:
- db-data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
restart: unless-stopped
# ---- Cache ----
cache:
image: redis:7-alpine
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- cache-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- backend
restart: unless-stopped
volumes:
db-data:
cache-data:
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # Backend not accessible externally