Docker Compose
Define multi-container applications with one YAML file - services, networks, volumes, healthchecks, profiles
Docker Compose
Compose lets you describe a multi-container app — say, a web service, a Postgres, and a Redis — in one YAML file, then docker compose up to bring the whole thing online.
It's the right tool for local development, CI test environments, and small single-host production deployments. For multi-host production, move to Kubernetes.
A Full-Stack Example
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
target: runner
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://user:pass@db:5432/myapp
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 256mb
volumes:
- redis-data:/data
volumes:
pgdata:
redis-data:Bring it up:
docker compose up -d # detached, build if needed
docker compose ps # see what's running
docker compose logs -f app # follow app's logs
docker compose exec app /bin/sh # shell into the app
docker compose down # stop and remove containers
docker compose down -v # also remove named volumes (destructive!)docker compose down -v deletes named volumes — the Postgres data goes with it. Reserve it for "wipe my dev env clean."
Service Discovery
Containers in the same Compose project share a network. Each service is reachable by its service name:
services:
app:
environment:
DATABASE_URL: postgres://user:pass@db:5432/myapp # 'db' resolves to the db service
REDIS_URL: redis://redis:6379 # 'redis' resolves to the redis serviceNo need to publish ports between services — only publish ports that humans (or the load balancer) need to reach.
depends_on and Healthchecks
depends_on controls start order, not "ready order." A service marked as a dependency just means "start me after this one." For "wait until it's actually accepting connections," combine with healthchecks:
app:
depends_on:
db:
condition: service_healthy # waits for healthcheck to pass
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
interval: 5s
retries: 10Without service_healthy, your app starts before Postgres has accepted connections — and crashes on the first connect.
Networks
By default Compose creates one network for the project, joining every service to it. Need finer control? Define networks explicitly:
services:
nginx:
image: nginx
networks: [frontend]
app:
build: .
networks: [frontend, backend]
db:
image: postgres:16-alpine
networks: [backend]
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # no external connectivity at allnginx and app talk on frontend; app and db talk on backend. db has no path to the outside world.
| Driver | Use case |
|---|---|
bridge | Default — isolated, one host |
host | Container shares host network (no isolation, no port mapping needed) |
none | No networking at all |
overlay | Multi-host (Docker Swarm only) |
Volumes
Three flavours, all useful:
services:
app:
volumes:
# Named volume — Docker-managed
- app-data:/app/data
# Bind mount — host directory; great for dev (live edits)
- ./src:/app/src:ro # ro = read-only
# tmpfs — in-memory, ephemeral
- type: tmpfs
target: /app/tmp
tmpfs:
size: 100m
volumes:
app-data:
driver: local # defaultRules of thumb:
| Use | When |
|---|---|
| Named volume | Persistent app data (DBs, queues, file storage) |
| Bind mount | Source code in dev; config files the host owns |
| tmpfs | Scratch space; secrets you don't want on disk |
:ro everywhere possible | One less mistake to make |
Environment
Three ways to inject environment variables:
services:
app:
# 1. Inline
environment:
NODE_ENV: production
LOG_LEVEL: info
# 2. From an .env-style file
env_file:
- .env
- .env.local
# 3. Substitution from your shell into the YAML
environment:
DATABASE_URL: "postgres://user:${DB_PASSWORD}@db/myapp"Variables in ${...} come from your shell or a .env file Compose auto-loads from the project directory. Don't commit .env if it contains secrets — gitignore it.
Profiles
For optional services (only-in-CI, only-with-monitoring):
services:
app:
build: .
db:
image: postgres:16-alpine
# Only starts when explicitly requested
prometheus:
image: prom/prometheus
profiles: [monitoring]
grafana:
image: grafana/grafana
profiles: [monitoring]docker compose up -d # app + db
docker compose --profile monitoring up -d # app + db + prometheus + grafanaCompose Overrides
Compose merges multiple files left-to-right. The convention:
| File | Role |
|---|---|
docker-compose.yml | The base — committed, shared |
docker-compose.override.yml | Auto-loaded; for local dev tweaks |
docker-compose.prod.yml | Production overrides, applied explicitly |
# docker-compose.override.yml — local dev
services:
app:
build:
target: development # different build stage in dev
volumes:
- .:/app # live source mount
environment:
LOG_LEVEL: debug
ports:
- "9229:9229" # node inspectordocker compose up -d # base + override
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -dResource Limits
Don't run dev or CI containers unlimited:
services:
app:
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128MLimits prevent one bad service from starving the others on a shared dev host.
Useful Commands
# Bring up just one service (and its deps)
docker compose up -d app
# Force a rebuild
docker compose build --no-cache
# See effective config after merging overrides
docker compose config
# Run a one-off command in the app's environment
docker compose run --rm app npm test
# Restart just one service
docker compose restart app
# Scale a service to N replicas (single host)
docker compose up -d --scale app=3Compose for Single-Host Production
It's not Kubernetes, but for small services it's fine. A few habits:
- Pin every image tag. No
latest. restart: unless-stoppedon everything you care about.- Healthchecks on everything, so restart policies actually work.
- Named volumes for state, bind mounts only for read-only config.
- Resource limits on every service.
- Reverse proxy (nginx, Caddy, Traefik) on the public side; everything else
internal: true. - Back up named volumes:
docker run --rm -v pgdata:/data -v "$PWD":/backup alpine tar czf /backup/pg-$(date +%F).tgz -C / data. - Upgrade with care:
docker compose pull && docker compose up -d(rolling for HA = Kubernetes; for one host, accept brief downtime or stand up the new stack in parallel and switch DNS).
What's Next
You can run multi-container apps end-to-end. Last piece: harden it for production — security, registries, image hygiene, ops habits → Best Practices.