1. What is Docker Compose?
Docker Compose is a tool for defining and running multi-container Docker applications. You describe your stack in a compose.yaml file - services, networks, volumes, environment variables - and Compose creates and wires everything together with a single command.
Compose is ideal for:
- Local development - spin up an API, database, cache, and message broker together.
- Integration testing - reproducible test environments in CI pipelines.
- Small deployments - single-host production stacks (with caveats; see Production Patterns).
- Demos and onboarding - one command to run the entire application.
┌─────────────────────────────────────────────────────────────────┐
│ docker compose up │
│ │ │
│ ├── creates network: myapp_default │
│ ├── creates volume: myapp_postgres_data │
│ ├── starts container: myapp-postgres-1 │
│ ├── starts container: myapp-redis-1 │
│ └── starts container: myapp-api-1 (depends on above) │
│ │ │
│ └── DNS: api, postgres, redis resolve on network │
└─────────────────────────────────────────────────────────────────┘
docker compose (space, not hyphen). The legacy standalone docker-compose Python tool is deprecated. Compose v2 is a Go-based Docker CLI plugin integrated with Buildx and Docker contexts.
Quick start
# Project layout
myapp/
├── compose.yaml
├── api/
│ └── Dockerfile
└── .env
# Start everything in the foreground
docker compose up
# Start detached, rebuild images
docker compose up -d --build
# Tear down containers, networks, and named volumes
docker compose down -v
2. The Compose File
Modern Compose files follow the Compose Specification (compose-spec.io). The top-level version field is no longer required - Compose v2 infers the schema automatically.
Top-level keys
| Key | Purpose |
|---|---|
services | Container definitions (required) |
networks | Custom bridge/overlay networks |
volumes | Named or external volume declarations |
secrets | File-based secrets mounted into containers |
configs | Non-sensitive config files (Swarm-oriented, limited in standalone) |
name | Override the default project name (directory name) |
Minimal compose.yaml
name: myapp
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
api:
build:
context: ./api
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:secret@db:5432/appdb
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 5
volumes:
postgres_data:
Build context options
services:
api:
build:
context: . # directory sent to docker build
dockerfile: docker/api.Dockerfile
target: production # multi-stage build target
args:
NODE_VERSION: "20"
cache_from:
- myregistry/api:cache
labels:
org.opencontainers.image.source: https://github.com/org/repo
image: myregistry/api:${TAG:-latest} # tag the built image
3. Services in Depth
Each entry under services maps to one or more containers (replicas with deploy.replicas in Swarm mode; standalone Compose runs one container per service).
Image vs build
| Approach | When to use |
|---|---|
image: nginx:1.27 | Pull a pre-built image from a registry |
build: ./api | Build locally from a Dockerfile |
| Both | Build locally and tag with image for push/reuse |
Common service options
services:
worker:
image: myapp/worker:2.1.0
restart: unless-stopped # no | always | on-failure | unless-stopped
user: "1000:1000" # run as non-root
working_dir: /app
command: ["node", "worker.js"] # override image CMD
entrypoint: ["/entrypoint.sh"] # override image ENTRYPOINT
init: true # use tini as PID 1 (reap zombies)
stop_grace_period: 30s
stop_signal: SIGTERM
read_only: true # read-only root filesystem
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
deploy: # ignored by standalone compose (Swarm only)
resources:
limits:
cpus: "0.5"
memory: 512M
reservations:
cpus: "0.25"
memory: 256M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Profiles
Profiles let you opt-in to optional services (debug tools, local mail catcher, etc.).
services:
api:
image: myapp/api
profiles: [] # always started (default profile)
mailhog:
image: mailhog/mailhog
profiles: ["debug"] # only with --profile debug
adminer:
image: adminer
profiles: ["tools"]
# Start api only (default)
docker compose up -d
# Include debug services
docker compose --profile debug up -d
4. Networking
Compose creates a default bridge network per project. Every service joins it automatically and is reachable by service name as a DNS hostname.
# From inside the api container:
curl http://redis:6379 # resolves to redis service
psql -h db -U app appdb # resolves to db service
Port publishing
services:
web:
ports:
- "8080:80" # host:container (bind all interfaces)
- "127.0.0.1:3000:3000" # localhost only
- "80" # ephemeral host port
internal-api:
expose:
- "8080" # visible to other services, NOT published to host
Custom networks
services:
frontend:
networks:
- frontend_net
api:
networks:
- frontend_net
- backend_net
db:
networks:
- backend_net # db not reachable from frontend
networks:
frontend_net:
driver: bridge
backend_net:
driver: bridge
internal: true # no external routing (isolated subnet)
External networks
networks:
shared_proxy:
external: true # must exist: docker network create shared_proxy
services:
web:
networks:
- shared_proxy
- default
frontend and db are on different networks with no shared network, db is not reachable from frontend.
5. Volumes
Compose supports three mount types: named volumes, bind mounts, and tmpfs.
| Type | Syntax | Use case |
|---|---|---|
| Named volume | postgres_data:/var/lib/postgresql/data | Database data, managed by Docker |
| Bind mount | ./src:/app/src | Live code reload in dev |
| Anonymous volume | /app/node_modules | Preserve container-only files over bind mount |
| External volume | external: true in top-level volumes | Share data across Compose projects |
services:
api:
volumes:
- ./api/src:/app/src:cached # macOS performance hint
- /app/node_modules # anonymous: shadow host node_modules
- api_logs:/var/log/app
db:
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d:ro
volumes:
postgres_data:
driver: local
api_logs:
legacy_data:
external: true
name: prod_postgres_data
Dev vs prod volume strategy
- Dev: bind-mount source code for hot reload; accept platform-specific file permission quirks.
- Prod: no source bind mounts; use named volumes only for persistent state; bake code into the image.
# Inspect volumes for a project
docker compose ps
docker volume ls --filter label=com.docker.compose.project=myapp
docker volume inspect myapp_postgres_data
6. Environment & Secrets
Environment variables
Compose supports inline env vars, .env files, and variable interpolation in the compose file itself.
# .env (loaded automatically from project directory)
TAG=2.1.0
DB_PASSWORD=localdev_secret
API_PORT=3000
# compose.yaml
services:
api:
image: myapp/api:${TAG}
ports:
- "${API_PORT}:3000"
environment:
NODE_ENV: development
LOG_LEVEL: ${LOG_LEVEL:-info} # default if unset
env_file:
- ./api/.env.local
- ./api/.env.shared
Interpolation and defaults
image: myregistry/api:${TAG:-latest}
ports:
- "${API_PORT:-3000}:3000"
# Required variable (Compose fails if unset)
image: myregistry/api:${TAG?TAG is required}
Secrets (file-based)
Secrets are mounted as files under /run/secrets/<secret_name>. Never commit secret files - add them to .gitignore.
services:
api:
secrets:
- db_password
- api_tls_cert
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
api_tls_cert:
file: ./secrets/api.crt
# Application reads:
# cat /run/secrets/db_password
.env files are convenient for local dev but are not encrypted. In production, inject secrets via your orchestrator, a secrets manager, or Docker Swarm secrets - never bake credentials into images or commit them to git.
7. Health Checks & Dependencies
Without health checks, depends_on only waits for the container to start, not for the application inside to be ready. Use condition: service_healthy for reliable startup ordering.
Health check syntax
services:
api:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s # grace period before failures count
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 5
depends_on with conditions
services:
api:
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
# api starts only after db healthcheck passes
worker:
depends_on:
api:
condition: service_healthy
| Condition | Behaviour |
|---|---|
service_started | Container is running (default) |
service_healthy | Healthcheck reports healthy |
service_completed_successfully | One-shot container exited 0 (migrations) |
One-shot migration pattern
services:
migrate:
image: myapp/api:${TAG}
command: ["npm", "run", "db:migrate"]
depends_on:
db:
condition: service_healthy
restart: "no"
api:
depends_on:
migrate:
condition: service_completed_successfully
db:
condition: service_healthy
# Check health status
docker compose ps
docker inspect --format='{{.State.Health.Status}}' myapp-api-1
8. Override Files
Compose automatically merges compose.yaml with compose.override.yaml if it exists. Override files let you keep a shared base and layer environment-specific changes without duplicating the entire stack.
Merge order
# Default: compose.yaml + compose.override.yaml
docker compose up
# Explicit file list (later files override earlier)
docker compose -f compose.yaml -f compose.prod.yaml up -d
# Dev-only override
docker compose -f compose.yaml -f compose.dev.yaml up
compose.yaml (shared base)
services:
api:
build: ./api
environment:
NODE_ENV: production
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
compose.override.yaml (local dev, gitignored or committed)
services:
api:
environment:
NODE_ENV: development
DEBUG: "app:*"
volumes:
- ./api/src:/app/src
ports:
- "3000:3000"
db:
ports:
- "5432:5432" # expose DB to host tools (pgAdmin, DBeaver)
compose.prod.yaml (production)
services:
api:
image: myregistry/api:${TAG}
build: !reset null # do not build on prod host
restart: unless-stopped
ports: !reset [] # no published ports; use reverse proxy
volumes: !reset [] # no source bind mounts
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
api:
condition: service_healthy
!reset to remove keys inherited from the base file, and extends (deprecated in spec) sparingly. Prefer explicit multi-file merges for clarity.
9. Compose CLI
All Compose commands are subcommands of docker compose. The project name defaults to the directory name and prefixes all resources (myapp-api-1, myapp_default).
Lifecycle commands
docker compose up -d # create and start in background
docker compose up -d --build # rebuild images before starting
docker compose up --watch # sync files and rebuild (Compose v2.22+)
docker compose stop # stop without removing
docker compose start # start stopped containers
docker compose restart api # restart one service
docker compose down # stop and remove containers + networks
docker compose down -v # also remove named volumes
docker compose down --rmi local # remove images built by compose
Inspection and debugging
docker compose ps # running services
docker compose ps -a # include stopped
docker compose logs -f api # follow api logs
docker compose logs --tail=50 db # last 50 lines
docker compose exec api sh # shell into running container
docker compose run --rm api npm test # one-off command (new container)
docker compose top api # show processes in service
docker compose config # render merged compose file
docker compose config --services # list service names
docker compose images # images used by services
docker compose port api 3000 # show host port mapping
Project and context
docker compose -p myproject up -d # override project name
docker compose --env-file .env.staging up -d
# Use a different Docker context (remote host)
docker context use production
docker compose up -d
10. Production Patterns
Compose on a single host can run production workloads, but understand the limits: no built-in rolling updates, no auto-scaling across nodes, and the host is a single point of failure. For multi-node production, use Kubernetes, Docker Swarm, or a PaaS.
Production checklist
- Pin image tags - never
:latestin production. - Build images in CI and push to a registry; pull on the host (do not build on prod).
- Use
restart: unless-stoppedon all long-running services. - Do not publish database ports to the host.
- Put TLS termination on a reverse proxy (nginx, Traefik, Caddy).
- Set resource limits and log rotation.
- Run containers as non-root; use read-only root filesystem where possible.
Reverse proxy with Traefik
services:
traefik:
image: traefik:v3.0
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./certs:/certs:ro
api:
image: myregistry/api:2.1.0
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.example.com`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.routers.api.tls=true"
expose:
- "3000"
Watchtower (automated image updates)
services:
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WATCHTOWER_POLL_INTERVAL: 300
WATCHTOWER_CLEANUP: "true"
# Only update labeled containers
command: --label-enable
Compose vs Kubernetes
| Concern | Compose | Kubernetes |
|---|---|---|
| Scale across nodes | No | Yes |
| Rolling updates | Manual / third-party | Built-in |
| Self-healing | Restart policy only | ReplicaSets, probes |
| Secrets management | File mounts | Secrets API, external operators |
| Best fit | Dev, CI, small single-host prod | Multi-node production |
11. Cheat Sheet
| Task | Command |
|---|---|
| Start stack | docker compose up -d |
| Rebuild and start | docker compose up -d --build |
| Stop and remove | docker compose down -v |
| View logs | docker compose logs -f <service> |
| Shell into service | docker compose exec <service> sh |
| Run one-off command | docker compose run --rm <service> <cmd> |
| Validate compose file | docker compose config |
| List services | docker compose config --services |
| Service DNS name | <service_name> on default network |
| Multi-file merge | docker compose -f a.yaml -f b.yaml up |
Full cheat sheet: Docker CLI Cheat Sheet