Why this matters
A real app is rarely one container: an API, its database, a proxy in front. Typing three long docker run commands, in the right order, with the right network and volumes, every time, is how mistakes happen. Docker Compose writes the whole set down in one file and starts it with one command.
What you need to know already: everything in this chapter so far - especially user-defined networks and DNS (11.15), volumes (11.22), restart policies (11.7); HEALTHCHECK (10.44); YAML syntax from netplan (8.11);
${VAR:-default}and${VAR:?msg}from bash (6.12).
One file for the whole stack
Compose reads compose.yaml in the current directory. Each entry under services: is one Compose service - one kind of container, with what you would otherwise pass to docker run:
# compose.yaml
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
POSTGRES_DB: orders
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
app:
build: .
environment:
DB_HOST: db
env_file: app.env
depends_on:
db:
condition: service_healthy
restart: unless-stopped
web:
image: nginx:1.27
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: [app]
volumes:
pgdata:
Reading it against docker run:
image:- the image;build: .- build one from the Dockerfile in this directoryenvironment:=-e;env_file:=--env-filevolumes:=-v(./nginx.confis a bind mount,:ro= read-only;pgdatais a named volume, declared in the top-levelvolumes:)ports:=-p;restart:=--restarthealthcheck:= the HEALTHCHECK from 10.44 (pg_isreadyasks postgres "can you take connections yet?")depends_on:- start order, explained below
# in a project directory with that compose.yaml (the compose mission builds one)
docker compose up -d
[+] Running 5/5
✔ Network orders_default Created 0.1s
✔ Volume "orders_pgdata" Created 0.0s
✔ Container orders-db-1 Healthy 6.2s
✔ Container orders-app-1 Started 6.4s
✔ Container orders-web-1 Started 6.5s
docker compose up -d creates everything and starts it in the background (-d as in docker run -d). Everything is named after the project, which defaults to the directory name (-p name, COMPOSE_PROJECT_NAME or a top-level name: override it):
network orders_default a user-defined bridge: DNS works
volume orders_pgdata
containers orders-db-1 orders-app-1 orders-web-1 project-service-number
images orders-app for services with build:
Each container joins the project network with its service name as a DNS alias, so app reaches the database as db:5432 - the container port, not a published one (11.15). version: "3.8" at the top of old files is obsolete; Compose v2 ignores it and prints the attribute \version\ is obsolete, it will be ignored.
depends_on is ordering, not readiness
depends_on: [db] only means "start db's container first". Postgres takes a few seconds to initialise; an app that connects immediately fails with Connection refused and exits - or crash-loops with a restart policy. Waiting until it is ready needs a healthcheck on the dependency and a condition:
depends_on:
db:
condition: service_healthy # wait until db's healthcheck passes
Other conditions: service_started (the default), service_completed_successfully (for a one-off job, like a database migration that must finish first). If the dependency never becomes healthy, up fails with dependency failed to start: container orders-db-1 is unhealthy. The application should still retry its connections - dependencies also restart later, when Compose is not there to order anything.
Two different kinds of "env"
.env (next to compose.yaml) feeds ${VAR} INTERPOLATION in the compose file itself
env_file: app.env variables INJECTED into the container's environment
environment: variables injected, written inline
Interpolation = replacing ${VAR} in the compose file with a value before Compose uses it, like bash expanding $VAR in a command line (6.12).
# same project
cat .env
DB_PASSWORD=s3cret
docker compose config | grep POSTGRES_PASSWORD
POSTGRES_PASSWORD: s3cret
docker compose config prints the file after interpolation - the fastest way to see what Compose will really do. Missing variables are a warning and an empty string:
WARN[0000] The "DB_PASSWORD" variable is not set. Defaulting to a blank string.
unless you use ${VAR:?message} (an error) or ${VAR:-default} - the same syntax as bash. Shell environment variables override .env. And never commit a .env that holds real credentials to git; commit a .env.example with placeholder values.
Profiles
adminer:
image: adminer
profiles: [debug]
A service with profiles: is skipped unless that profile is switched on: docker compose --profile debug up -d (or COMPOSE_PROFILES=debug). Handy for debug tools (adminer is a web UI for databases), load generators and one-off jobs that live next to the stack.
The everyday commands
docker compose up -d create/update and start everything
docker compose up -d --build rebuild images for build: services first
docker compose ps status of this project's containers
docker compose logs -f app logs, prefixed by service (-f follow)
docker compose exec db psql -U postgres a command in a running service (psql = postgres shell)
docker compose run --rm app ./migrate a one-off container for a service
docker compose restart app restart (does NOT pick up config changes)
docker compose down stop + remove containers and the network
docker compose down -v ... and the named volumes: THE DATA
Note exec and logs take the service name (db), not the container name (orders-db-1).
up -d is idempotent - running it again changes nothing unless the file changed: edit the file, run it again, and Compose recreates only the services whose configuration changed. restart does not - it restarts the old containers with the old config (11.7). And down keeps volumes; down -v deletes them, which is the right way to reset a dev database and the wrong command to run by habit.
Where Compose stops
Compose runs a project on one host. No spreading containers across machines, no rolling updates with health gates, no self-healing beyond restart policies. It is the right tool for local development, integration tests in build pipelines and small single-host deployments.
Later (Ch 15): Kubernetes runs the same ideas - services, DNS by name, health-gated start, injected env, named volumes - across many machines.
What you can now do
- write a compose file for an app, its database and a proxy
- make an app wait until its database is actually ready
- tell
.envinterpolation from variables injected into containers