Docker Compose
Why this matters
You just wired up a two-container app by hand: create a network, build
an image, run each container with the right flags, remember the exact
docker run invocations for next time. That doesn't scale past a couple
of containers, and it's not reproducible for a teammate (or future you).
Compose lets you describe your whole multi-container app — services,
networks, volumes — in one YAML file and bring it all up or down with a
single command.
Concepts
One file describes one project
A compose.yaml file (the current standard filename; you'll also see the
older docker-compose.yml, both work) describes one project: a set of
services, the networks connecting them, and the volumes they use. Running
docker compose up reads that file and does, in one step, everything you
did by hand in module 05.
compose.yaml docker compose up
┌─────────────────────┐ creates one project:
│ services: │
│ web: build ./web │──┐ ┌──── project "compose-lab" ─────┐
│ cache: redis:7 │──┤ ──► │ network: compose-lab_default │
│ volumes: │ │ │ ┌────────┐ ┌───────────┐ │
│ cachedata: │──┘ │ │ web │──► │ cache │ │
└─────────────────────┘ │ │ :8000 │DNS │ redis │ │
│ └────────┘ └────┬──────┘ │
│ volume:cachedata│
└─────────────────────────────────┘
one YAML file = network + volumes + services, wired automatically
A service is one component of the app
A service is a named entry under services: describing how to run one
component: which image or build: context, its ports, environment,
volumes, and dependencies. Compose creates one or more containers per
service.
build vs image
A service either builds from a local Dockerfile (build: ./web) or pulls
a pre-built image (image: redis:7-alpine). You can specify both to build
locally and tag the result. build: is the Compose equivalent of docker build; image: is the equivalent of docker pull.
depends_on controls order, not readiness
depends_on controls startup order — Compose starts the depended-on
service's container first — but by default does not wait for it to be
actually ready to accept connections. A database's container process
starting and the database accepting connections are different moments.
Adding condition: service_healthy (paired with a healthcheck: on the
dependency) makes Compose actually wait for health, the reliable way to
sequence "app waits for database."
depends_on (order only) depends_on + condition: service_healthy
cache container STARTED cache healthcheck PASSES
│ │
▼ (immediately) ▼ (waits for readiness)
web starts — db may not web starts — db proven ready
be accepting connections yet to accept connections
Networks and volumes come for free
Compose creates a project-scoped user-defined bridge network and attaches
every service to it — so services reach each other by service name via
DNS, exactly like module 05's user-defined network, now automatic. Named
volumes are declared once under a top-level volumes: key and referenced
by service, the same concept as module 04's named volumes but written as
code.
Project name and variable substitution
By default Compose derives the project name from the directory
containing compose.yaml, and prefixes resource names (network, volume)
with it, so multiple projects don't collide. Compose also reads a .env
file in the project directory and substitutes ${VARNAME} references in
the YAML, letting you parameterize image tags or ports without editing the
file. Compose adds no new runtime concept beyond modules 02-05 — it's
declarative automation over build, network create, volume create,
and run.
In Docker Desktop: a Compose project appears in the Containers tab as a single collapsible group named after the project, with its services nested under it. You can start/stop the whole project from that group, click into any service's container for its Logs/Inspect/Terminal sub-tabs, and see health status per service — the GUI view of
docker compose ps.
Command reference
| Command | What it does | Example |
|---|---|---|
docker compose up |
Builds (if needed) and starts all services in the foreground | docker compose up |
docker compose up -d |
Same, but detached (background) | docker compose up -d |
docker compose up --build |
Forces a rebuild of images before starting | docker compose up --build |
docker compose down |
Stops and removes containers and the project's default network | docker compose down |
docker compose down -v |
Also removes named volumes declared in the file | docker compose down -v |
docker compose ps |
Lists this project's containers and status | docker compose ps |
docker compose logs [-f] [service] |
Shows logs for all or one service | docker compose logs -f web |
docker compose exec <service> <cmd> |
Runs a command inside a running service's container | docker compose exec web bash |
docker compose build |
Builds images for services with a build: key, without starting |
docker compose build |
docker compose config |
Renders the fully resolved config (after substitution) | docker compose config |
docker compose stop |
Stops containers without removing them | docker compose stop |
Flag breakdown for the multi-flag Compose commands:
docker compose up -d:upbuilds any images that don't exist yet, creates the network, and starts every service.-d(detached) runs them in the background and returns your prompt, instead of streaming interleaved logs in the foreground.
docker compose up --build:--buildforces images to be rebuilt first, even if one already exists — needed after Dockerfile or dependency changes.
docker compose down -v:downstops and removes the project's containers and default network.-vadditionally deletes the named volumes declared in the file — destroys persisted data, so use it deliberately.
docker compose logs -f web:-f(follow) streams new log lines instead of printing and exiting.weblimits output to that one service (omit it for all services).
Hands-on exercises
You'll create everything inline — the app, its Dockerfile, and the Compose file. Nothing is downloaded.
(WSL2 Ubuntu terminal) Create the project directory, the web app under
./web, and a minimalcompose.yaml:mkdir -p ~/learn-docker/compose-lab/web && cd ~/learn-docker/compose-lab cat > web/app.py <<'EOF' from flask import Flask app = Flask(__name__) @app.get("/health") def health(): return {"status": "ok"} if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) EOF cat > web/requirements.txt <<'EOF' flask==3.0.3 EOF cat > web/Dockerfile <<'EOF' FROM python:3.12-slim WORKDIR /code COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . EXPOSE 8000 CMD ["python", "app.py"] EOF cat > compose.yaml <<'EOF' services: web: build: ./web ports: - "8000:8000" EOF docker compose up -d docker compose ps curl -s http://localhost:8000/healthExpect
docker compose psto showwebrunning and thecurlto return{"status":"ok"}.(Docker Desktop GUI, then CLI) See the project in the GUI. Open the Containers tab: there's now a group named
compose-labwithwebnested under it. Expand it, clickweb, and open its Logs sub-tab to see Flask's startup output. Cross-check what Compose created behind the scenes from the CLI:docker network ls | grep compose-lab docker compose configExpect a network named
compose-lab_default(the project prefix you saw as the group name in the GUI), anddocker compose configto print the fully resolved YAML.(WSL2 Ubuntu terminal) Add a second service (Redis) and a named volume:
cat > compose.yaml <<'EOF' services: web: build: ./web ports: - "8000:8000" environment: - REDIS_URL=redis://cache:6379 depends_on: - cache cache: image: redis:7-alpine volumes: - cachedata:/data volumes: cachedata: EOF docker compose up -d docker compose psExpect two services,
webandcache, both running. In the GUI, thecompose-labgroup now nests both.(WSL2 Ubuntu terminal) Confirm service-name DNS resolution works between them (same mechanism as module 05, now automatic):
docker compose exec web sh -c "apt-get update -qq && apt-get install -y -qq iputils-ping >/dev/null; ping -c 2 cache"Expect
pingto succeed, resolvingcacheto its container IP.(WSL2 Ubuntu terminal) Observe the
depends_onordering limitation. Stop everything, then bring it up in the foreground:docker compose down docker compose upWatch the interleaved logs, then Ctrl+C after a few seconds. Notice
cachestarts beforeweb, but Compose doesn't verify Redis is actually accepting connections before startingweb— only that its container process began. For a small, fast image likeredisthis is rarely a problem, but it's whycondition: service_healthyexists.(WSL2 Ubuntu terminal) Add a healthcheck-gated dependency:
cat > compose.yaml <<'EOF' services: web: build: ./web ports: - "8000:8000" environment: - REDIS_URL=redis://cache:6379 depends_on: cache: condition: service_healthy cache: image: redis:7-alpine volumes: - cachedata:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 volumes: cachedata: EOF docker compose up -d docker compose psExpect
cacheto show(healthy)andwebto have started only after that health check passed. Docker Desktop shows the same(healthy)marker oncachein the project group.(WSL2 Ubuntu terminal) Confirm the named volume persists across a
down(without-v) and a freshup:docker compose exec cache redis-cli set marker "still-here" docker compose down docker compose up -d docker compose exec cache redis-cli get markerExpect
"still-here"to print — the named volumecachedataoutlived thedown, same principle as module 04.(WSL2 Ubuntu terminal) Now use
-vto wipe volumes and confirm the data is gone:docker compose down -v docker compose up -d docker compose exec cache redis-cli get markerExpect the last command to print
(nil)—-vremovedcachedata, so a fresh, empty volume was created onup.Diagnose and fix: a service that exits immediately in Compose. Add a deliberately one-shot third service:
cat >> compose.yaml <<'EOF' oneshot: image: alpine command: echo "done" EOF docker compose up -d docker compose ps -a docker compose logs oneshotExpect
oneshotto showExited (0)almost immediately — same root cause as module 02's exercise 9 (the process finished, so the container has nothing left to run), visible in the GUI project group too. Fix it (if you actually wanted a long-running service) with a command that doesn't exit, e.g.command: sh -c "while true; do sleep 3600; done"— or recognize this is expected for genuinely one-shot tasks and not a bug. Remove theoneshotblock afterward to keep the file clean.(WSL2 Ubuntu terminal) Full cleanup:
docker compose down -v docker compose ps -a docker volume lsExpect no containers or volumes left over from this project. In Docker Desktop, the
compose-labgroup disappears from the Containers tab.
Independent challenge
No commands given here — figure it out yourself using what you know from this module and earlier ones.
Task: Take the two-container setup you wired up by hand in module 05 (the app plus a second service on a shared network) and reproduce the entire thing as a single compose.yaml, with no manual docker network create, docker volume create, or individual docker run commands. Your file must give the app a published port, attach a second service (a database or Redis from an official image), give that second service a named volume for its data, and make the app wait for the second service to be genuinely ready — not merely started — before it comes up. Then prove two things: that the two services resolve each other by service name (module 05's DNS, now automatic), and that the second service's data survives a teardown that keeps volumes but is wiped by the teardown that removes them.
Stuck? One hint
You need a top-level volumes: key referenced by the second service, a healthcheck: on that service using a tool the image actually ships, and depends_on: with condition: service_healthy on the app — then contrast docker compose down with docker compose down -v.
Common mistakes & troubleshooting
- Editing a running service's
build:context and expectingdocker compose upalone to pick it up.upwithout--buildreuses an existing image; usedocker compose up --build(ordocker compose buildfirst) after Dockerfile or dependency changes. - Assuming
depends_onmeans "wait until ready." Withoutcondition: service_healthy(and ahealthcheck:on the dependency), it only guarantees start order — a frequent cause of "my app crashed on startup because the database wasn't accepting connections yet." - Running
docker compose downwhen you meantdown -v, or vice versa. Plaindownkeeps named volumes (data survives);-vdeletes them. Know which you want before running it against real data. - Forgetting that Compose prefixes resource names with the project
(directory) name, then being confused why
docker network lsordocker volume lsshows names you didn't type — expected, and it avoids collisions between projects. - Mixing up
docker compose execanddocker compose run.execruns a command in an already-running service container;runstarts a brand-new one-off container from that service's image (useful for a migration) — not interchangeable. - Not noticing a service failed its healthcheck and everything
downstream is stuck waiting.
docker compose ps(and the health marker in Docker Desktop's project group) shows health directly — check it before assuming a "stuck"upis a networking problem.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
Show questions
- What single command tears down all of a Compose project's containers and its default network, and what extra flag also removes named volumes?
- Why doesn't
depends_onalone guarantee that a dependency service is actually ready to accept connections before a dependent service starts? - What mechanism fixes the limitation in question 2, and what two things does it require you to configure?
- If you edit a service's Dockerfile and just run
docker compose up -dagain without--build, will your changes take effect? Why or why not? - How does DNS-based service discovery in Compose relate to what you learned about user-defined bridge networks in module 05?
- What's the difference between
docker compose execanddocker compose run? - How does a Compose project appear in Docker Desktop's Containers tab, and what can you do to it from there?
Show answers
docker compose downtears down containers and the default network; adding-valso removes named volumes declared in the file.depends_ononly sequences container start order — it confirms the dependency's container process has begun, not that the application inside it has finished initializing and is accepting connections.depends_on: <service>: condition: service_healthy, which requires the dependency to define ahealthcheck:block Compose can poll to determine actual readiness.- No —
docker compose upwithout--buildreuses an existing image; you needdocker compose up --buildor a priordocker compose buildto pick up Dockerfile or source changes. - Compose automatically creates a project-scoped user-defined bridge
network and attaches every service to it, giving free DNS resolution by
service name — the exact mechanism module 05 set up manually with
docker network createand--network. execruns an additional command inside an already-running service container;runstarts a brand-new, separate one-off container from that service's image/config, useful for tasks like a migration.- As a single collapsible group named after the project, with its services nested inside — you can start/stop the whole project, and click any service for its Logs/Inspect/Terminal sub-tabs and health status.
Further reading & sources
- Docker: Docker Compose overview - the top-level introduction to describing multi-container apps declaratively.
- Compose file reference - the authoritative specification for every key (
services,volumes,networks,depends_on,healthcheck). - Compose: Control startup and shutdown order - explains the
depends_on/condition: service_healthyreadiness distinction this module stresses. - docker compose CLI reference - reference for
up,down,ps,logs,exec, and their flags. - Compose: Environment variables and .env files - how project naming and
${VAR}substitution work.
Next
Continue to 07-multi-stage-builds-and-optimization to shrink and speed up the images you've been building all along.