Docker Compose
Multi-Container Apps in YAML — and Why It's Not Kubernetes
Docker Compose is the single-host answer to "I have five services and I want them
to come up together." You write a YAML file describing each service — image, ports,
volumes, environment, dependencies — and docker compose up starts them
on the same daemon. Networking, volume creation, healthcheck wiring, and start
ordering are all derived from the file.
Compose v2 (the Go binary, since 2021) replaced the original Python docker-compose.
It ships as a Docker CLI plugin, runs as docker compose, and tracks the
Compose Specification — the open spec maintained jointly with several other tools.
Recent versions added profiles, healthcheck conditions, and watch mode for fast
inner-loop development.
What a Compose File Becomes
Key Numbers
A Real-World compose.yml
name: myapp
services:
web:
image: nginx:alpine
ports:
- "80:80"
depends_on:
api:
condition: service_healthy
networks: [front, back]
restart: unless-stopped
api:
build:
context: ./api
target: production # multi-stage target
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
env_file:
- .env.production
secrets:
- jwt_signing_key
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
networks: [back]
deploy:
resources:
limits: { cpus: '2', memory: 1G }
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
networks: [back]
pgadmin:
image: dpage/pgadmin4
ports: ["5050:80"]
profiles: [tools] # only with --profile tools
networks: [back]
networks:
front:
back:
volumes:
pgdata:
secrets:
db_password:
file: ./secrets/db_password.txt
jwt_signing_key:
file: ./secrets/jwt.pem Service Networks
# Compose creates a per-project bridge network by default.
# Services on the same network can resolve each other by name.
$ docker network ls --filter label=com.docker.compose.project=myapp
NETWORK ID NAME DRIVER SCOPE
abc123def456 myapp_back bridge local
def789abc012 myapp_front bridge local
# Inside 'api' container, 'db' resolves to db's IP via Docker's embedded DNS
api$ getent hosts db
172.20.0.5 db
# Cross-network communication: only services on shared networks can talk.
# 'web' is on front+back; 'pgadmin' is on back only — they CAN talk.
# 'web' and a service on a third network CANNOT talk. Healthchecks and depends_on Conditions
depends_on:
db:
condition: service_healthy # wait for db's healthcheck to pass
cache:
condition: service_started # default: wait for container to start
init:
condition: service_completed_successfully # wait for one-shot to exit 0
# Healthcheck states: starting, healthy, unhealthy
$ docker inspect <cid> --format '{{.State.Health.Status}}'
healthy
# Common patterns
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/health || exit 1"]
interval: 30s # how often
timeout: 5s # max time per check
retries: 3 # consecutive failures before unhealthy
start_period: 60s # grace period during which failures don't count Profiles
services:
app:
image: myapp
db:
image: postgres:16
pgadmin:
profiles: [tools]
image: dpage/pgadmin4
load-test:
profiles: [test]
image: myapp-loadtest
# Default: only 'app' and 'db' come up
$ docker compose up
# Add the tools profile
$ docker compose --profile tools up
# Now app + db + pgadmin
# Multiple profiles
$ docker compose --profile tools --profile test up Watch Mode (v2.22+)
services:
api:
build: ./api
develop:
watch:
- action: sync
path: ./api/src
target: /app/src
ignore:
- node_modules/
- action: rebuild
path: ./api/package.json
# Run with watch
$ docker compose up --watch
# Editing api/src/server.js syncs into the container; nodemon restarts.
# Editing api/package.json triggers a full rebuild. Compose vs Kubernetes
| Aspect | Compose | Kubernetes |
|---|---|---|
| Scope | Single host (one Docker daemon) | Cluster of nodes |
| Networking | Docker bridge networks | CNI plugin (overlay or eBPF) |
| Reconciliation | Imperative (up/down/restart) | Declarative (controllers reconcile state) |
| Service discovery | Embedded DNS, service name | kube-dns + Service resource |
| Storage | Named volumes, bind mounts | PV / PVC / StorageClass |
| Best for | Dev, CI, small prod | Multi-tenant clusters at scale |
Tradeoffs
- Local development environments — one command, full stack
- CI test environments — same compose file as dev
- Small single-host production deployments
- Tutorials and reproducible demos
- No multi-host scheduling (swarm exists but is in maintenance mode)
- No rolling updates beyond restart policies
- No autoscaling, no HA primitives
- YAML is ephemeral — no controller-style drift correction
Frequently Asked Questions
What's the difference between Compose v1 and v2?
Compose v1 was a Python tool (docker-compose, with a hyphen) installed separately. Compose v2 (docker compose, with a space) is a Go binary shipped as a Docker CLI plugin since 2021. v2 is faster, supports the unified Compose Specification spec maintained at compose-spec.io, and is what new installations get by default. v1 has been EOL since mid-2023. Compose files (compose.yml, docker-compose.yml) are mostly compatible between them, but only v2 supports newer features like profiles and watch mode.
depends_on doesn't actually wait for readiness — why?
Plain depends_on only waits for the container to be running, not for the app inside to be ready. A web service depending on Postgres will start as soon as the postgres container is up — even if Postgres hasn't finished initializing. Use depends_on with condition: service_healthy combined with a healthcheck on the dependency. Compose then waits until docker reports the container as healthy before starting the dependent. The healthcheck can be any command that exits 0 when the service is ready (e.g., pg_isready, curl localhost/health).
How does Compose differ from Kubernetes?
Compose is a single-host orchestrator: services run on one Docker daemon, networking is just a bridge network the daemon manages. Kubernetes is a cluster orchestrator: services span many nodes, networking is via CNI plugins (Calico, Cilium), scheduling is via the scheduler. Compose has no concept of declarative reconciliation — you 'docker compose up' and that's it. Kubernetes constantly reconciles desired state via controllers. Compose is dramatically simpler and is the right choice for dev environments, CI, and small single-host production deployments. Kubernetes is for fleets.
When to use profiles?
Profiles let you tag services so they only start when explicitly requested. Common pattern: a base compose file defines the core services (app, db); a 'tools' profile defines optional ones (admin UI, metrics dashboard) that are useful for debugging but not for normal runs. 'docker compose up' runs default services; 'docker compose --profile tools up' adds the tagged ones. Cleaner than maintaining multiple compose files for slightly different scenarios.
What does watch mode do?
Watch mode (added in Compose v2.22, 2023) tells Compose to re-sync files into running containers as you edit them on the host, without rebuilding the image. You declare actions per file pattern: 'sync' copies file changes into the container; 'rebuild' triggers a full rebuild for changes that need it (e.g., Dockerfile changes). It's the docker-compose answer to Skaffold or Tilt for Kubernetes — fast inner-loop development without losing the production-like environment.
env_file vs environment vs secrets — which when?
environment hard-codes values into the compose file (visible in 'docker inspect'). env_file references a file that's read at compose time and the values are baked into the container's environment (also visible in inspect). Both are fine for non-sensitive config. For real secrets (API keys, DB passwords), use the secrets feature: secrets are mounted as files inside the container at /run/secrets/<name>, never appearing in the environment. With swarm mode they're encrypted; in single-host they're just bind-mounted from a file or external store.