Real applications aren't a single container. They're a web of services — APIs, databases, caches, workers — that must start together, talk to each other, and shut down cleanly. Compose gives you declarative, reproducible multi-container environments in a single file.
1. The Problem
A typical web application needs at minimum:
- An API server (Node, Python, Go…)
- A database (Postgres, MySQL, MongoDB…)
- A cache (Redis, Memcached…)
- Maybe a background worker, a message queue, or a reverse proxy
Running each manually with docker run means:
# Create network
docker network create myapp
# Start database
docker run -d --name db --network myapp \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:16
# Start Redis
docker run -d --name redis --network myapp redis:7-alpine
# Build and start API
docker build -t myapi ./api
docker run -d --name api --network myapp \
-p 3000:3000 \
-e DATABASE_URL=postgres://db:5432/app \
myapi
That's three commands just to start — plus you need to remember the order, the flags, the environment variables, and to create the network first. Now imagine tearing it all down, or sharing this setup with a teammate. It's tedious, error-prone, and undocumented.
The problem isn't typing — it's knowledge loss. Two weeks later, you won't remember which env vars the API needs, which ports to map, or what volume name you used. The setup lives in your shell history, not in version control.
2. What is Compose?
Docker Compose is a tool that reads a YAML file (compose.yaml) describing your multi-container application and manages its entire lifecycle with simple commands.
- Declarative — you describe the desired state, Compose figures out what to create/start/stop
- Single file — all services, networks, and volumes defined in one place
- Single command —
docker compose upstarts everything;docker compose downstops everything - Version-controlled — the file lives in your repo, documenting your stack
Compose runs containers on a single host. It doesn't handle multi-node scheduling, rolling updates, or self-healing. It's designed for local development and testing. For production multi-host workloads, you need Kubernetes or Docker Swarm.
File naming: The modern standard is compose.yaml. The older docker-compose.yml still works but is legacy. No version: key is needed in modern Compose files.
3. The compose.yaml Anatomy
Here's a complete, annotated example — a Node API backed by Postgres and Redis:
# compose.yaml — Full-stack application
services:
api:
build: ./api # Build from Dockerfile in ./api
ports:
- "3000:3000" # Expose to host
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/app
REDIS_URL: redis://redis:6379
depends_on:
- db
- redis
develop:
watch:
- action: sync
path: ./api/src
target: /app/src
db:
image: postgres:16 # Use pre-built image
volumes:
- pgdata:/var/lib/postgresql/data # Persist data
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine # Lightweight Redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata: # Named volume declaration
Key Breakdown
| Key | Purpose | Example |
|---|---|---|
services | Top-level map of all containers in the app | services: { api, db, redis } |
image | Use a pre-built image from a registry | image: postgres:16 |
build | Build an image from a Dockerfile path | build: ./api |
ports | Map host port → container port (external access) | "3000:3000" |
environment | Set environment variables in the container | POSTGRES_PASSWORD: secret |
volumes | Mount named volumes or bind mounts for persistence | pgdata:/var/lib/postgresql/data |
depends_on | Control startup order (not readiness!) | depends_on: [db, redis] |
healthcheck | Define how to test if the service is ready | test: ["CMD", "redis-cli", "ping"] |
4. Networking in Compose
Compose automatically creates a default network for your project. Every service joins it. The magic:
- Services resolve each other by service name —
db,redis,apiare DNS names - No port mapping needed between services — they communicate on internal ports directly
ports:is only needed for external access (host → container)- The network is named
<project>_default(project = directory name by default)
When the API connects to postgres://db:5432/app, Docker's embedded DNS resolves db to the container's IP on the Compose network. No hardcoded IPs, no --link (deprecated), no manual network creation.
5. Key Commands
| Command | Purpose |
|---|---|
docker compose up -d | Start all services in detached mode |
docker compose down | Stop and remove containers, networks |
docker compose down -v | Also remove named volumes (data loss!) |
docker compose ps | List running services and their status |
docker compose logs -f api | Follow logs for a specific service |
docker compose exec db psql -U postgres | Run a command inside a running service |
docker compose build | Rebuild images (when Dockerfiles change) |
docker compose pull | Pull latest images for services using image: |
docker compose restart api | Restart a single service |
# Typical workflow
$ docker compose up -d
[+] Running 4/4
✔ Network myapp_default Created
✔ Volume "myapp_pgdata" Created
✔ Container myapp-db-1 Started
✔ Container myapp-redis-1 Started
✔ Container myapp-api-1 Started
$ docker compose ps
NAME SERVICE STATUS PORTS
myapp-api-1 api running 0.0.0.0:3000->3000/tcp
myapp-db-1 db running 5432/tcp
myapp-redis-1 redis running 6379/tcp
$ docker compose logs api --tail 5
myapp-api-1 | Server listening on port 3000
myapp-api-1 | Connected to PostgreSQL
myapp-api-1 | Connected to Redis
$ docker compose down
[+] Running 4/4
✔ Container myapp-api-1 Removed
✔ Container myapp-redis-1 Removed
✔ Container myapp-db-1 Removed
✔ Network myapp_default Removed
6. depends_on & Startup Order
The depends_on key controls startup order — but has a critical subtlety:
By default, depends_on only waits for the dependency container to start — not for the service inside to be ready. Postgres takes seconds to initialize; your API might crash connecting before it's ready.
The Fix: Health-Based Dependencies
services:
api:
build: ./api
depends_on:
db:
condition: service_healthy # Wait for healthcheck to pass
redis:
condition: service_healthy
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
With condition: service_healthy, Compose waits until the dependency's healthcheck passes before starting the dependent service. This is the correct way to handle startup ordering.
| Condition | Waits For |
|---|---|
service_started | Container started (default) |
service_healthy | Healthcheck passing |
service_completed_successfully | Container exited with code 0 (for init tasks) |
7. Profiles & Overrides
Override Files
Compose automatically merges compose.yaml with compose.override.yaml if present. Use this for dev-specific configuration:
# compose.override.yaml — dev overrides (auto-loaded)
services:
api:
build:
target: development # Use dev stage of multi-stage build
volumes:
- ./api/src:/app/src # Hot reload with bind mount
environment:
DEBUG: "true"
db:
ports:
- "5432:5432" # Expose DB to host for GUI tools
Profiles
Profiles let you define optional services that only start when explicitly requested:
services:
api:
build: ./api
ports: ["3000:3000"]
db:
image: postgres:16
# Only starts with --profile debug
pgadmin:
image: dpage/pgadmin4
ports: ["8080:80"]
profiles: [debug]
environment:
PGADMIN_DEFAULT_EMAIL: admin@local.dev
PGADMIN_DEFAULT_PASSWORD: admin
# Only starts with --profile monitoring
prometheus:
image: prom/prometheus
profiles: [monitoring]
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
# Start core services only
$ docker compose up -d
# Start core + debugging tools
$ docker compose --profile debug up -d
# Start everything
$ docker compose --profile debug --profile monitoring up -d
Interactive Quizzes
Hands-On Task
Create a three-service application: a Node.js API, Postgres database, and Redis cache.
Step 1: Create the project structure
mkdir compose-lab && cd compose-lab
mkdir api
Step 2: Create api/package.json
{
"name": "compose-lab-api",
"scripts": { "start": "node server.js" },
"dependencies": { "express": "^4.18.0", "pg": "^8.11.0", "redis": "^4.6.0" }
}
Step 3: Create api/server.js
const express = require('express');
const { Pool } = require('pg');
const { createClient } = require('redis');
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = createClient({ url: process.env.REDIS_URL });
async function start() {
await redis.connect();
app.get('/', async (req, res) => {
const dbResult = await pool.query('SELECT NOW() as time');
await redis.incr('hits');
const hits = await redis.get('hits');
res.json({ db_time: dbResult.rows[0].time, hits });
});
app.listen(3000, () => console.log('API on :3000'));
}
start();
Step 4: Create api/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
Step 5: Create compose.yaml
services:
api:
build: ./api
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/app
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
Step 6: Run it
# Start everything
docker compose up -d
# Check status
docker compose ps
# Test the API
curl http://localhost:3000
# → {"db_time":"2024-...","hits":"1"}
# Hit it again
curl http://localhost:3000
# → {"db_time":"2024-...","hits":"2"}
# Check logs
docker compose logs api
# Connect to Postgres
docker compose exec db psql -U postgres -d app -c "SELECT NOW();"
# Tear it all down
docker compose down
✅ Success criteria: The API returns database time and an incrementing hit counter from Redis. All three services start with one command and stop with one command.
Podman Compose — a drop-in replacement that uses Podman instead of Docker. Same compose.yaml, same commands, rootless by default.
Podman play kube — Podman can also run Kubernetes YAML directly: podman play kube compose.yaml. This creates pods (groups of containers sharing a network namespace) — closer to how Kubernetes works.
Nerdctl — containerd's CLI also supports nerdctl compose up with the same file format.
The dominant workflow in the industry:
- Local dev:
compose.yaml— fast iteration, simple commands - CI/testing:
docker compose upin a pipeline for integration tests - Production: Kubernetes manifests (Deployments, Services, ConfigMaps) — multi-node, self-healing, scalable
Tools like Kompose can convert a compose.yaml into Kubernetes manifests as a starting point. But production configs always diverge — don't expect a 1:1 translation.
Key Takeaways
- Compose = declarative multi-container — one YAML file defines your entire local stack
- Service names are DNS names — containers reach each other by service name, no manual networking
- ports: is for external access only — services communicate internally without port mapping
- depends_on ≠ ready — use
condition: service_healthywith healthchecks for real ordering - Named volumes persist data — survive
docker compose down(unless you add-v) - Profiles for optional services — keep your default startup fast, add debugging/monitoring on demand
- Compose is single-host only — for production multi-node, graduate to Kubernetes or Swarm
- Version control your compose.yaml — it's documentation, reproducibility, and onboarding in one file