One docker run line with fifteen flags is already hard to review. Four of them, in the right order, with a shared network and two volumes, is not something anyone should be typing. Compose puts the whole stack in a YAML file that lives in your repository.
Note the modern spelling: docker compose (a subcommand of Docker, v2) rather than the old standalone docker-compose Python tool. The old form still works in many places and the file format is compatible, but v2 is what you should be writing against.
Topic 1: A Complete Stack in One File
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes:
- pgdata:/var/lib/postgresql/data
networks: [backend]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
api:
build:
context: .
dockerfile: Dockerfile
target: runtime
environment:
DATABASE_URL: postgres://app@db:5432/appdb
depends_on:
db:
condition: service_healthy
networks: [backend, frontend]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 15s
start_period: 20s
restart: unless-stopped
proxy:
image: nginx:1.27-alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
api:
condition: service_healthy
networks: [frontend]
restart: unless-stopped
volumes:
pgdata:
networks:
frontend:
backend:
secrets:
db_password:
file: ./secrets/db_password.txt
Everything in the previous six lessons is in there: images and tags, a build context, named volumes, user-defined networks, healthchecks, restart policies, port publishing. Compose is not a new set of concepts — it is a declarative front end for the ones you already have.
Two design points in that file are worth stating explicitly, because they are the difference between a demo and something defensible:
The database port is not published. api reaches db:5432 over the backend network by service name. Publishing 5432 would expose the database to anything that can reach the host, for no benefit at all.
proxy is not on backend. It cannot reach the database even if it is compromised. Two networks and three services is the smallest useful piece of segmentation, and it costs four lines.
Topic 2: The Commands
docker compose up -d # build if needed, create networks/volumes, start everything
docker compose ps # status and health of each service
docker compose logs -f api # follow one service's logs
docker compose logs -f # all services, interleaved and colour-coded
docker compose exec api sh # shell into a running service
docker compose run --rm api npm test # one-off container with the service's config
docker compose build --no-cache api # rebuild one service from scratch
docker compose restart api
docker compose stop # stop, keep containers and volumes
docker compose down # stop and remove containers and networks
docker compose down -v # ...and DELETE the named volumes
docker compose config # render the fully resolved file — the debugging command
docker compose config is the one people discover too late. It prints the file after variable substitution, extends resolution and override merging, which turns “why is it using the wrong port” into a five-second answer.
docker compose down -v deletes your volumes. It is the correct way to reset a development database and a genuinely bad thing to type on a host running anything you care about.
Topic 3: depends_on Does Less Than You Think
Compose starts services in dependency order — that part is true and it has been true since v1, driven by depends_on, links and volumes_from.
What plain depends_on does not do is wait for a service to be ready. It waits for the container to be started. Postgres takes several seconds after container start before it accepts connections, so a plain depends_on: [db] reliably gives you an API that crashes on its first connection attempt.
There are two correct fixes and one workaround:
depends_on:
db:
condition: service_healthy # requires a healthcheck on db. This is the right answer.
depends_on:
migrate:
condition: service_completed_successfully # for one-shot init/migration containers
And the workaround worth keeping anyway: make your application retry its dependencies. Even with health gating, a database can restart at any point in the container’s life, and an application that dies permanently the first time a connection fails is fragile regardless of startup ordering. Health gating fixes startup; retry logic fixes the other 99% of the uptime.
This is also the practical reason HEALTHCHECK from lesson 6 earns its place. A healthcheck that nothing reads is decoration; a healthcheck that gates dependent services is infrastructure.
Topic 4: One Definition, Several Environments
You do not want three unrelated compose files that drift. Compose gives you three mechanisms to avoid that.
Variable substitution with a .env file:
services:
api:
image: acme/api:${API_VERSION:-1.4.2}
ports:
- "${API_PORT:-8080}:8080"
The :-default syntax matters — without it, an unset variable silently becomes an empty string and produces confusing errors.
Override files. docker compose up automatically merges compose.yaml with compose.override.yaml if the latter exists. Keep the shared definition in the base and the development conveniences in the override:
# compose.override.yaml — development only, not committed to prod deploys
services:
api:
build:
target: dev
volumes:
- ./src:/app/src # live source bind mount (lesson 8)
environment:
LOG_LEVEL: debug
ports:
- "9229:9229" # debugger port
For anything else, be explicit:
docker compose -f compose.yaml -f compose.prod.yaml up -d
Profiles keep optional services out of the default run:
services:
mailhog:
image: mailhog/mailhog
profiles: [dev]
docker compose --profile dev up -d # includes mailhog
docker compose up -d # does not
Topic 5: Taking Compose to Production
Compose in production is a legitimate choice on a single host — an internal tool, a small service, a staging environment. It is not an orchestrator: no scheduling across machines, no rolling updates, no self-healing beyond restart policies. If you need those, the answer is Kubernetes.
Within that boundary, the changes a compose file needs before it leaves a laptop:
Pin every image. No :latest, no floating tags. Version or digest.
Set restart policies. restart: unless-stopped on every long-running service, for the reasons in lesson 3.
Set resource limits. Compose’s deploy.resources section applies with docker compose up in v2:
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
memory: 256M
Bind published ports deliberately. "127.0.0.1:8080:8080" rather than "8080:8080" for anything that sits behind a proxy on the same host.
Configure logging. The default json-file driver grows without limit and has filled more disks than any other single Docker default:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Get secrets out of the file. environment: values are visible in docker inspect, in docker compose config, and in the process environment of anything inside the container. Use Compose secrets (mounted as files under /run/secrets/, as in the Topic 1 example) or inject from a real secret manager. Never commit a password to a compose file, and never commit the .env that holds one — add it to .gitignore and .dockerignore both.
Add a log aggregator or shipping sidecar if this host is one you will actually have to debug remotely.
Try it yourself: Take the Topic 1 file, remove the condition: service_healthy from api, and run docker compose up. Watch the API fail its first database connection. Add the condition back and watch Compose wait. That is the whole difference between “started” and “ready”, demonstrated in two runs.
Common mistake: Treating docker compose down as the way to stop a stack for the night. It removes containers and networks, which is usually fine — but down -v is one keystroke away and it removes your volumes. Use docker compose stop when you mean stop.