Real applications are rarely a single container. A typical stack might include a web frontend, an API, a database, a cache, and a background worker. Running them with individual docker run commands is fragile and tedious. Docker Compose declares the entire stack in one YAML file.
A Realistic Example
Save as compose.yaml (the modern filename; older guides use docker-compose.yml):
services:
web:
image: nginx:1.27
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- api
api:
build: ./api
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7-alpine
volumes:
pgdata:
Bring it up:
docker compose up -d # start everything in the background
docker compose ps # see what's running
docker compose logs -f api # tail one service's logs
docker compose exec api sh # shell into the api container
docker compose down # stop and remove containers and networks
docker compose down -v # also remove named volumes (destructive!)
Anatomy of a Compose File
| Top-level key | Purpose |
|---|---|
services | The containers that make up your app |
networks | Custom networks (Compose creates a default one if you omit this) |
volumes | Named volumes that persist across compose down |
configs | Configuration files mounted into services |
secrets | Secret values mounted as files (more secure than env vars) |
Per-service options
| Key | Purpose |
|---|---|
image | Pull this image from a registry |
build | Build from a local Dockerfile |
ports | Publish ports to the host |
environment / env_file | Set environment variables |
volumes | Mount named volumes or bind mounts |
depends_on | Order startup; supports health-check conditions |
healthcheck | How Compose probes liveness |
restart | Restart policy (no, on-failure, always, unless-stopped) |
command / entrypoint | Override the image's defaults |
deploy.replicas | Run N copies of the service (Swarm; Compose ignores in basic mode) |
Networking and Service Discovery
Compose creates a private network shared by all services in the project. Each service is reachable from the others via its service name as a DNS hostname. In the example above, the API connects to Postgres at db:5432 and Redis at cache:6379. No IP juggling.
Profiles for Optional Services
Use profiles to scope services to specific scenarios — a heavy ML container only used during data ingest, an admin tool only needed locally:
services:
app:
image: myapp
pgadmin:
image: dpage/pgadmin4
profiles: ["debug"]
docker compose up # starts only "app"
docker compose --profile debug up # starts both
Override Files
Compose automatically merges compose.yaml with compose.override.yaml if present — perfect for local-only tweaks (extra mounts, debug ports). For environment-specific configurations, supply multiple files:
docker compose -f compose.yaml -f compose.prod.yaml up -d
When to Outgrow Compose
Compose is excellent for:
- Local development of multi-service apps
- Small single-host deployments (a side project, an internal tool)
- CI environments that need an integration database/queue/etc.
Compose is not built for:
- Multi-node clusters with auto-failover (use Kubernetes)
- Rolling updates with health gating
- Service meshes, advanced ingress, secrets rotation
The good news: a Compose file translates almost line-for-line to a Kubernetes manifest. Tools like Kompose automate the conversion, and the mental model — services, networks, volumes — carries over directly.