Why Docker Compose?
A single container is fine with docker run. Real projects rarely are:
- An Nginx front end
- A Node.js / Python / Java backend API
- A PostgreSQL / MySQL database
- A Redis cache
Managing those by hand with docker run turns into a nightmare: port mappings, volume mounts, networking, environment variables, startup order — dozens of flags per command, and none of it version-controlled.
Docker Compose describes the whole multi-container stack in one declarative YAML file, and a single command starts, stops, or rebuilds every service. It is the standard tool for local development, CI pipelines, and small production deployments.
Installation and Versions
Docker Desktop (Windows / macOS) ships with Compose. On Linux, install docker-compose-plugin separately and use docker compose (V2, recommended) rather than docker-compose (V1, no longer maintained).
# Verify the installation
docker compose version
# Docker Compose version v2.27.0
Core Concepts
Services
Each service defines one container. The simplest possible example:
# docker-compose.yml
services:
web:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html
Networks
Compose creates a network per project by default, and services in the same docker-compose.yml can reach each other by service name — no IP addresses required:
services:
web:
image: nginx:alpine
depends_on:
- api
api:
image: node:20-alpine
# inside the web container: http://api:3000
You can also define custom networks to isolate tiers:
services:
web:
networks:
- frontend
api:
networks:
- frontend
- backend
db:
networks:
- backend
networks:
frontend:
backend:
Now web cannot reach db directly, because they share no network.
Volumes
Containers are ephemeral — delete one and its data goes with it. Persist data with volumes:
services:
db:
image: postgres:16
volumes:
# Named volume: managed by Docker, survives container removal
- pgdata:/var/lib/postgresql/data
# Bind mount: maps a host path directly, ideal for hot-reloading code
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
volumes:
pgdata:
The two mount types:
| Type | Syntax | Use for |
|---|---|---|
| Named volume | name:/path/in/container |
Persisting runtime data such as databases |
| Bind mount | ./host/path:/container/path |
Syncing source code and config during development |
Environment Variables
Three ways to configure them:
services:
api:
image: myapp:latest
environment:
- NODE_ENV=production
- LOG_LEVEL=info
# or, from a file
env_file:
- .env
# file format: KEY=value, one per line
Prefer a .env file, and add it to .gitignore so secrets never leak. Compose also reads a .env in the project root automatically for variable interpolation:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD} # read from .env
.env file:
DB_PASSWORD=supersecret
In Practice: A Typical Web Stack
Here is a complete production-grade configuration (Nginx + Node API + PostgreSQL + Redis):
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- api
restart: unless-stopped
api:
build:
context: ./backend
dockerfile: Dockerfile
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://app:${DB_PASSWORD}@db:5432/app
- REDIS_URL=redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redisdata:/data
restart: unless-stopped
volumes:
pgdata:
redisdata:
For the reverse-proxy half of that stack, see the Nginx config cheat sheet.
Command Quick Reference
# Start every service (detached)
docker compose up -d
# Show status
docker compose ps
# Tail logs (follow)
docker compose logs -f api
# Rebuild and restart (after a code change)
docker compose up -d --build
# Restart a single service
docker compose restart api
# Shell into a container
docker compose exec api sh
# Stop and remove containers and networks (volumes kept)
docker compose down
# Stop and remove everything including volumes (careful: database data is lost)
docker compose down -v
# Watch resource usage
docker compose stats
More everyday commands are in the Docker command cheat sheet.
Common Patterns and Best Practices
1. Hot Reload in Development
Mount the source and enable file watching:
services:
api:
build: ./backend
volumes:
- ./backend:/app
- /app/node_modules # anonymous volume so the host does not shadow it
command: npm run dev
environment:
- CHOKIDAR_USEPOLLING=true # works around file watching on Docker Desktop for Windows
2. Multiple Environments
Layer several compose files:
# Base + development overrides
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Base + production overrides
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Example docker-compose.dev.yml (overrides the command, exposes a debug port):
services:
api:
command: npm run dev
ports:
- "9229:9229" # Node.js inspector
environment:
- NODE_ENV=development
3. Healthchecks and Startup Order
depends_on only guarantees the container started, not that the service is ready. Combine healthcheck with condition:
services:
api:
depends_on:
db:
condition: service_healthy
This avoids the classic trap of the API booting before the database is accepting connections.
4. Resource Limits
In production, cap CPU and memory so one container cannot take down the host:
services:
api:
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
memory: 256M
5. Log Rotation
Keep logs from filling the disk:
services:
api:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Common Pitfalls
The .env File Is Not Picked Up
- It must live in the directory where you run
docker compose, not necessarily where the compose file sits ${VAR}interpolation happens at YAML parse time, which is a different mechanism from injectingenvironmentinto the container- Debug with
docker compose configto see the fully merged result
Bind Mount Permission Problems (Linux)
When a host file is mounted into a container, a UID mismatch between the container process and the host causes write errors. Two fixes:
# Option 1: run as a non-root user with a matching UID
services:
api:
user: "${UID}:${GID}"
# Option 2: chown from an init container after mounting
Port Already Allocated
docker compose up reports port is already allocated, yet docker ps shows nothing:
# Check what holds the host port
# Windows
netstat -ano | findstr :8080
# Linux/macOS
lsof -i :8080
Something outside Docker usually holds it; switch to another port.
Slow File Mounts on Windows
With the WSL2 backend, mounting a Windows path (/c/...) into a container carries significant IO overhead. Put the project inside the WSL2 filesystem (\\wsl$\Ubuntu\home\...) instead — typically 5–10x faster.
Compose vs Swarm vs Kubernetes
| Scenario | Tool |
|---|---|
| Single-host development / small deployment | Docker Compose |
| Multi-host clustering / high availability | Docker Swarm (lightweight) or Kubernetes (rich ecosystem) |
| Enterprise-grade production orchestration | Kubernetes |
A docker-compose.yml deploys straight to Docker Swarm via docker stack deploy, but Kubernetes requires converting to manifests (Kompose can help).
Summary
Docker Compose promotes a multi-container application from a pile of shell commands to declarative, version-controlled, reproducible configuration. Master the four core concepts — services, volumes, networks, environment — add healthchecks, multi-file overrides, and resource limits, and you can cover the vast majority of local development and small production scenarios. When you outgrow a single node, migrate to Kubernetes.
| Key point | Notes |
|---|---|
| services | Define each container: image, ports, dependencies |
| volumes | Named volumes persist data; bind mounts sync code |
| networks | Services reach each other by name; custom networks isolate |
| environment | A .env file plus interpolation is the best practice |
| depends_on + healthcheck | Guarantees startup order and actual readiness |
| multi-file override | dev.yml / prod.yml per environment |
Handy alongside this: convert command-line flags to YAML with our docker run to compose tool, and when you eventually move to a cluster, keep the Kubernetes command cheat sheet close.