Docker Compose¶
What Compose actually is¶
Everything Compose does, docker CLI commands could do manually:
docker network create, docker volume create, then a docker run per
service with the right --network, -v, -e, and -p flags, in the
right order. Compose's actual job is to read one YAML file describing
that desired end state — which services exist, what they need, how
they're connected — and reconcile the running containers to match it,
so none of that has to be typed by hand or scripted separately.
The single most important mechanical fact: Compose automatically
creates one shared custom network for the whole project and attaches
every service to it. This is why, inside a Compose project, a service
named api can reach a service named db at the hostname db with zero
extra configuration — it's the exact
custom-bridge-network DNS mechanism
from the previous page, just wired up automatically instead of by hand.
Anatomy of a compose file¶
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/appdb
depends_on:
db:
condition: service_healthy
networks:
- app-net
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d appdb"]
interval: 5s
timeout: 3s
retries: 5
networks:
- app-net
volumes:
db-data:
networks:
app-net:
Reading the file top to bottom: build: . tells Compose to build an
image from the Dockerfile in the current directory for the api
service, rather than pulling a pre-built one (db uses image: instead,
pulling postgres:16 directly — a service uses one or the other, not
usually both). ports publishes a port to the host, exactly like
docker run -p. environment sets environment variables inside the
container — here, api's database connection string references db by
its service name, which resolves via the automatic network DNS. The
top-level volumes: and networks: blocks declare the named
volume/network that services reference by name.
depends_on doesn't mean "wait until ready" — unless told to¶
The problem: plain depends_on: [db] (a list, not the map form
above) only waits for the db container to start — not for
PostgreSQL inside it to finish initializing and start accepting
connections. Postgres takes a moment after its process starts before
it's actually ready to accept queries. Without more, api can start,
immediately try to connect, and crash on the very first connection
attempt — a classic "works on the second docker compose up, fails on
the first" bug, because the second run's db might already be warm
from before.
The fix, shown in the compose file above: a healthcheck on db
that actually probes readiness (pg_isready), combined with
depends_on: db: condition: service_healthy on api. Now Compose
delays starting api until db's healthcheck reports healthy, not just
"container started" — the same distinction a
Dockerfile HEALTHCHECK
exists to make visible in the first place, used here to gate startup
ordering instead of just external monitoring.
.env files and variable substitution¶
# .env
POSTGRES_PASSWORD=pass
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
Compose automatically reads a .env file in the project directory and
substitutes ${VAR} references in the compose file itself (not just
inside environment: — anywhere in the YAML). This keeps
environment-specific or secret values out of the committed compose file
— but the .env file itself must then not be committed either
(add it to .gitignore), or the secret just moved files instead of
actually being protected.
Common commands¶
docker compose up -d # start everything, detached
docker compose logs -f api # follow logs for one service
docker compose exec api sh # shell into a running service's container
docker compose down # stop and remove containers + the auto-created network
docker compose down -v # also remove named volumes (data loss — deliberate)
down (without -v) removes containers and networks but leaves named
volumes intact — restarting with up -d again reattaches to the same
db-data volume, and Postgres finds its data still there. This is the
correct default: containers are disposable, volumes are not, unless -v
explicitly says otherwise.
Common pitfall¶
Using bare depends_on: [db] (list form) on a service whose startup
depends on another service being functionally ready, not just
running, is the single most common Compose bug — it looks correct,
works often enough in casual testing to seem fine, and then fails
intermittently in CI or on a cold start specifically because timing
varies. If a service crashes on startup with a connection-refused error
against a dependency that "should" be up, check whether depends_on has
a condition: service_healthy backed by a real healthcheck, not just
the container-started list form.