Docker Compose: Multi-Container Applications (2026)
Docker Compose defines your entire multi-container application in a single YAML file — services, networks, volumes, environment variables and startup order. Instead of juggling multiple docker run commands, one docker compose up starts everything. Compose V2 (built into Docker CLI as docker compose) is the current standard. This phase covers writing production-grade Compose files, dependency ordering with health checks, environment management, override files for dev/prod parity, profiles for optional services and the new watch mode for live reload.
Table of Contents
Compose File Structure
# docker-compose.yml (or compose.yml — Compose V2 prefers the shorter name)
# Top-level keys: services, networks, volumes, configs, secrets
name: myapp # Project name (default: directory name) — used as prefix for containers
services:
# Each key under services is a service name
web: # → container name: myapp-web-1
...
db: # → container name: myapp-db-1
...
redis:
...
networks:
backend: # Named network shared by services
driver: bridge
volumes:
postgres_data: # Named volume — persists between container restarts
redis_data:
# Compose V2 vs V1:
# V1: docker-compose (hyphen) — Python binary, deprecated
# V2: docker compose (space) — Go plugin, built into Docker CLI
# Always use V2: docker compose up (not docker-compose up)
# Remove the top-level 'version:' key — it's ignored in V2 and causes warnings
Service Definitions
# Full-featured compose.yml for a Node.js app + Postgres + Redis
name: myapp
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
NODE_ENV: production
cache_from:
- myapp-web:latest # Use previous build as cache source
image: myapp-web:latest # Tag the built image
container_name: myapp-web # Fixed name (avoid in multi-replica setups)
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://myuser:mypass@db:5432/mydb
REDIS_URL: redis://redis:6379
env_file:
- .env.production
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- frontend
- backend
volumes:
- ./logs:/app/logs # Bind mount for logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: mydb
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypass
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql # Run on first start
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myuser -d mydb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
- backend
networks:
frontend:
backend:
internal: true # No external internet access (db/redis can't reach outside)
volumes:
postgres_data:
redis_data:
Dependency Ordering
# depends_on controls startup order, but has two modes:
# 1. service_started (default) — waits for container to START, not be ready
# Problem: Postgres takes 3-5 seconds to accept connections after starting
depends_on:
db:
condition: service_started # Web starts immediately after db container starts
# App may crash if it connects before Postgres is ready
# 2. service_healthy — waits for the healthcheck to pass ✅ Use this
depends_on:
db:
condition: service_healthy # Web only starts after db passes its healthcheck
redis:
condition: service_started # Redis starts fast, no healthcheck needed
# 3. service_completed_successfully — for one-shot init containers
services:
db-migrate:
image: myapp-web:latest
command: npm run db:migrate
depends_on:
db:
condition: service_healthy
web:
depends_on:
db-migrate:
condition: service_completed_successfully
db:
condition: service_healthy
# restart on failure — handle transient startup failures
web:
restart: on-failure
# Compose will restart web if it exits with non-zero
# (handles the case where db isn't quite ready despite healthcheck)
Environment Files
# Three ways to pass env vars to services:
# 1. Inline environment: (committed to git — for non-secrets)
environment:
NODE_ENV: production
PORT: "3000"
# 2. env_file: (load from file — file can be gitignored)
env_file:
- .env # Always loaded
- .env.local # Optional override (doesn't error if missing in Compose V2+)
# 3. Variable substitution — use host shell variables in compose.yml
services:
web:
image: myapp:${TAG:-latest} # ${TAG} from shell, fallback to 'latest'
environment:
SECRET_KEY: ${SECRET_KEY} # Must be set in shell or .env
# .env file (in project root — auto-loaded by Compose for variable substitution)
# This is different from env_file: — it's for compose.yml variable substitution
# .env:
TAG=v1.2.3
POSTGRES_PASSWORD=supersecret
# .env.production (passed to containers via env_file:)
NODE_ENV=production
LOG_LEVEL=info
DATABASE_URL=postgres://user:${POSTGRES_PASSWORD}@db:5432/mydb
# Environment variable precedence (highest → lowest):
# 1. Shell environment variables
# 2. .env file in project directory (for substitution in compose.yml)
# 3. environment: key in compose.yml
# 4. env_file: files listed in compose.yml
# 5. ENV in Dockerfile
Volumes and Mounts
# Named volumes — managed by Docker, persist between compose down/up
volumes:
postgres_data: # Empty = default bridge driver, data on Docker host
postgres_data:
driver: local
driver_opts:
type: nfs
o: addr=nfsserver,rw
device: ":/exports/data" # NFS volume example
# Bind mounts — map host directory into container (good for development)
services:
web:
volumes:
- ./src:/app/src # Live code reload — host file changes appear in container
- ./config:/app/config:ro # Read-only bind mount
# tmpfs — in-memory, not persisted (good for sensitive temp files)
services:
web:
tmpfs:
- /tmp
- /run
# Anonymous volumes — created automatically, deleted with container
services:
web:
volumes:
- /app/node_modules # Don't overwrite node_modules from host bind mount!
# This is the classic Node.js volume trick:
# services:
# web:
# volumes:
# - .:/app # Bind mount entire project
# - /app/node_modules # Anonymous volume "shadows" node_modules — uses container's version
# compose down volume behavior
docker compose down # Removes containers + networks; keeps named volumes
docker compose down -v # Also removes named volumes (data lost!)
docker compose down --remove-orphans # Also remove containers not in compose.yml
Override Files
# Compose merges multiple files — use this for dev/prod parity without duplication.
# Default merge order: compose.yml → compose.override.yml (auto-loaded)
# compose.yml — base (shared config, committed to git)
services:
web:
image: myapp-web:${TAG:-latest}
environment:
NODE_ENV: production
networks:
- backend
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
# compose.override.yml — development overrides (auto-merged, can be gitignored)
services:
web:
build: . # Build locally instead of using registry image
ports:
- "3000:3000" # Expose ports locally
volumes:
- .:/app # Live reload: mount source into container
- /app/node_modules
environment:
NODE_ENV: development
LOG_LEVEL: debug
db:
ports:
- "5432:5432" # Expose DB port for local tools (TablePlus, pgAdmin)
# compose.prod.yml — production overrides (explicit, not auto-merged)
services:
web:
restart: unless-stopped
deploy:
replicas: 3
resources:
limits:
cpus: '0.5'
memory: 512M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Usage:
docker compose up # Uses compose.yml + compose.override.yml
docker compose -f compose.yml -f compose.prod.yml up -d # Production
Profiles
# Profiles let you define optional services that only start when requested.
# Use for: monitoring tools, admin UIs, test databases, mock services.
services:
web:
# No profile — always starts
image: myapp-web:latest
db:
image: postgres:16-alpine
# No profile — always starts
pgadmin:
image: dpage/pgadmin4
profiles: [tools] # Only starts when 'tools' profile is active
ports:
- "5050:80"
environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_PASSWORD: admin
prometheus:
image: prom/prometheus
profiles: [monitoring]
ports:
- "9090:9090"
grafana:
image: grafana/grafana
profiles: [monitoring]
ports:
- "3001:3000"
mailhog:
image: mailhog/mailhog
profiles: [tools, dev] # Starts with either 'tools' or 'dev' profile
ports:
- "8025:8025" # Web UI for catching dev emails
# Usage:
docker compose up # Only web + db
docker compose --profile tools up # web + db + pgadmin + mailhog
docker compose --profile monitoring up # web + db + prometheus + grafana
docker compose --profile tools --profile monitoring up # Everything
Essential Commands
# Start everything (detached)
docker compose up -d
# Start and rebuild images
docker compose up -d --build
# Start specific services only
docker compose up -d web redis
# Stop everything (keep containers)
docker compose stop
# Stop and remove containers + networks
docker compose down
# View logs
docker compose logs -f # All services
docker compose logs -f web # Single service
docker compose logs --tail 50 web
# Run a one-off command in a service
docker compose run --rm web npm run db:seed
docker compose run --rm web sh # Debug shell
# Execute in a running service
docker compose exec web sh
docker compose exec db psql -U myuser -d mydb
# Scale a service
docker compose up -d --scale web=3
# Pull latest images for all services
docker compose pull
# See what's running
docker compose ps
docker compose ps --services # Just service names
# Restart a single service
docker compose restart web
# Watch mode — live sync for development (Compose V2.22+)
# compose.yml:
# services:
# web:
# develop:
# watch:
# - action: sync
# path: ./src
# target: /app/src
# - action: rebuild
# path: package.json
docker compose watch # Auto-syncs file changes, rebuilds on Dockerfile/package.json changes
Next: Phase 5 — Docker Networking covers bridge, host and overlay networks, DNS resolution between containers, network isolation patterns and exposing services securely.