Docker 06 — Docker Compose
SplitEase doesn’t run alone anymore — it needs Postgres for data and Redis for caching, so “run the app” is really “run three programs, wire them onto one network, and feed each the right env vars.” Doing that by hand is a wall of docker run flags you’ll fat-finger every single time. Docker Compose lets you declare the entire stack in one file and bring it all up — in the right order — with one command.
The Goal
By the end of this module you can:
- Write a complete, correct
docker-compose.ymlfor the SplitEase stack: app, Postgres, Redis - Explain the difference between
buildandimage, and when each one is used - Wire services together so the app reaches Postgres by name (
db:5432) — no IPs, no guessing - Persist Postgres data with a named volume so it survives
docker compose down - Fix the classic startup race with a healthcheck and
condition: service_healthy— the difference between “container started” and “database actually ready” - Drive the stack:
up,up -d,down,down -v,logs,ps
The Lesson
The pain Compose removes
In the last module (Docker 05 — Data & Networking) you ran two containers by hand and wired them together. To run the real SplitEase stack the manual way, you’d do something like this every time:
docker network create splitease-net
docker run -d --name db --network splitease-net \
-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=splitease \
-v splitease-pgdata:/var/lib/postgresql/data \
postgres:16
docker run -d --name cache --network splitease-net redis:7
docker run -d --name app --network splitease-net -p 8080:8080 \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/splitease \
-e SPRING_DATASOURCE_PASSWORD=secret \
splitease-api
Four commands, a hand-made network, a named volume, and a fistful of env vars you have to remember exactly. Get one flag wrong and the app can’t find the database. Onboard a teammate and they re-type all of this from memory. This is fragile, undocumented, and exactly the kind of “works on my machine” trap Docker is supposed to kill.
Compose replaces all of it with a single declarative file. You describe what the stack is — the services, their images, their wiring — and Compose figures out how to create it. One file, checked into the repo, that anyone clones and runs.
The whole file, end to end
Here is a complete, correct docker-compose.yml for SplitEase. Read it once top to bottom, then we’ll walk every part.
services:
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/splitease
SPRING_DATASOURCE_USERNAME: splitease
SPRING_DATASOURCE_PASSWORD: secret
SPRING_DATA_REDIS_HOST: cache
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:16
environment:
POSTGRES_DB: splitease
POSTGRES_USER: splitease
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U splitease -d splitease"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7
volumes:
pgdata:
Three services, one named volume, about 30 lines. That single file is the SplitEase runtime, version-controlled and reproducible. Now the walkthrough.
services — the things that run
services is the top-level map, and each key (app, db, cache) is one container Compose will create. The service name is also its hostname on the private network Compose builds automatically — that’s why the app’s datasource URL says db:5432 and its Redis host is cache. This is the service-name DNS you met in Docker 05: Compose puts every service on one network and registers each name, so they reach each other by name, never by IP.
You don’t write docker network create here. Compose makes a network for the project and joins all services to it. That’s a whole manual step gone.
build vs image
Two ways a service gets its image, and the difference matters:
| Key | What it means | Used for |
|---|---|---|
build: . | Build an image from your Dockerfile in this directory | app — your own code that you change |
image: postgres:16 | Pull a prebuilt image from a registry | db, cache — software you don’t write |
app uses build: ., so Compose runs your Dockerfile (the one from Docker 03) to build the SplitEase image fresh. db and cache use image:, pulling official Postgres and Redis straight from Docker Hub — you’d never write a database from scratch, so you just declare which published image you want. Pin the tag (postgres:16, not postgres:latest) so the version can’t drift under you.
ports, environment, volumes
ports: - "8080:8080"— publish a container port to your laptop. Left side is the host port you type in the browser, right side is the port inside the container. Onlyapphas it, because only the app is something you reach from outside.dbandcachehave noports: they’re reachable on the private network by the app, and you deliberately don’t expose your database to the host. (Format is"HOST:CONTAINER"— quote it so YAML doesn’t mangle5432:5432-style values.)environment— the env vars each container boots with. This is where the app learns its database URL and password, and where Postgres learns which DB and user to create on first run. These replace every-eflag from the manual version.volumes—dbmounts the named volumepgdataat Postgres’s data directory/var/lib/postgresql/data. Named volumes are declared once at the bottom under the top-levelvolumes:key. This is the lesson from Docker 05: without it, your expense data lives only in the container’s writable layer and vanishes the moment the container is removed. With it, the data outlives the container.
depends_on + healthchecks — the race that bites everyone
Here’s the part that separates people who use Compose from people who understand it.
Postgres does not accept connections the instant its container starts. The container process launches in milliseconds, but Postgres then spends a few seconds initialising its data directory and opening its socket. There is a window where the container is running but not ready.
Now watch what happens when the app starts during that window. Toggle this between the two modes:
A bare depends_on: db only promises order of starting — “don’t start the app until the db container has started.” It says nothing about whether Postgres is actually ready to answer. So in “no healthcheck” mode, Compose sees the db container exist, immediately launches the app, the app dials db:5432, Postgres isn’t listening yet, and you get:
org.postgresql.util.PSQLException: Connection to db:5432 refused.
Check that the hostname and port are correct and that the postmaster
is accepting TCP/IP connections.
The app crashes on boot. The maddening part: run it again and it might work, because the second time Postgres had a head start. This is a textbook race condition — a flake that depends on timing, not on a bug in your code.
The fix is two pieces working together:
- Give
dba healthcheck — a command Compose runs on a loop to ask “are you actually ready?” For Postgres that’spg_isready, the official “can you accept connections?” probe. Until it passes, Compose marks the servicestarting; once it passes,healthy. - Make the app depend on that health, not just on existence:
depends_on:
db:
condition: service_healthy
Now Compose holds the app back until db reports healthy. The app only starts once Postgres can genuinely answer — so its first connection to db:5432 succeeds, every time. That’s the “with healthcheck” mode in the visualizer.
The healthcheck knobs: interval (how often to probe), timeout (how long one probe may take), retries (how many failures before “unhealthy”). The defaults above — probe every 5s, allow 5 failures — give Postgres up to ~25s to come up, which is plenty.
Redis comes up fast and the app doesn’t crash without it, so cache uses the lighter condition: service_started — wait for it to start, don’t gate on a healthcheck. Match the strictness to how much the app actually needs each dependency to be ready.
Why this matters in a real job: “depends_on doesn’t wait for ready, only for started — you need a healthcheck and condition: service_healthy” is a genuine senior-vs-junior interview answer, and it’s the root cause of a huge share of “the app works locally but flakes in CI/Docker” tickets. Knowing it saves you and your team hours.
Driving the stack
Once the file exists, the day-to-day commands are short:
docker compose up # build (if needed) + start everything, logs stream to your terminal
docker compose up -d # same, detached — runs in the background, gives you your prompt back
docker compose ps # what's running, and each service's health
docker compose logs -f app # follow the app's logs (drop -f to just dump them)
docker compose down # stop and remove the containers + the network
docker compose down -v # ALSO delete the named volumes — wipes your Postgres data
Two traps worth burning in:
docker compose downkeeps your volumes, so your data survives a restart.docker compose down -vdeletes them. Use-vwhen you want a clean database; never run it when you care about the data.- Changed your app code or Dockerfile?
upwon’t rebuild by default. Rundocker compose up --buildto force a fresh image.
(It’s docker compose, two words — the modern plugin. Old tutorials show docker-compose with a hyphen, the legacy standalone binary. Use the spaced version.)
A dev override for live reload
When you’re developing, you don’t want to rebuild the image on every code change. Compose automatically merges a compose.override.yml (if present) on top of the base file, so you keep a separate dev tweak without touching the committed stack:
# compose.override.yml — picked up automatically, great for dev
services:
app:
volumes:
- ./src:/app/src # bind-mount your source so changes show without a rebuild
The base file stays the clean, production-shaped definition; the override carries machine-specific dev conveniences. Don’t ship the override to production — it exists to make your inner loop fast.
Build This
Containerise the full SplitEase stack and prove the app waits for the database correctly.
-
In your
splitease-apirepo (the one with the Dockerfile from Docker 03), createdocker-compose.ymlwith the three services from the walkthrough above —app(built from.),db(Postgres 16, named volume, healthcheck), andcache(Redis 7). Giveappadepends_onwithcondition: service_healthyondb. -
Bring it up and watch the order:
docker compose upYou should see Postgres log that it’s ready before the app finishes booting — the app should not throw “connection refused.” If your app needs the DB schema, let it create it on boot or run your migrations as usual.
-
In a second terminal, confirm the stack is healthy and reachable:
docker compose ps curl http://localhost:8080/actuator/healthpsshould showdbashealthyandappasrunning; the health endpoint should return{"status":"UP"}(or hit any real SplitEase endpoint and get a 200). -
Tear it down:
docker compose down
Prove the lesson (optional but worth it): temporarily remove the healthcheck and change the app’s depends_on to a bare db: (no condition). Run docker compose up a few times — you should be able to make the app crash on “connection refused” by losing the race. Put the healthcheck back and watch it become rock-solid.
Definition of done: the entire stack — app + Postgres + Redis — comes up with a single docker compose up, the app reliably connects to db:5432 on the first try because it waited for db to be healthy, the health endpoint returns UP, and docker compose down cleans everything up.
Where to Practice
| What | Where | Time |
|---|---|---|
| Compose getting-started walkthrough (multi-service app) | docs.docker.com — “Get Started” → Compose | 45 min |
The docker-compose.yml reference (every key, looked up as needed) | docs.docker.com — Compose file reference | ongoing |
| Run a multi-container stack in-browser, nothing to install | labs.play-with-docker.com | 30 min |
| Your real stack | the SplitEase repo on your machine | 1 hr |
How to Practice
Type the compose file by hand at least once — don’t paste it — so the structure (services → each service → its keys) lives in your fingers. Then drill the failure on purpose: delete the healthcheck, make the app lose the race, see the exact “connection refused” error, then fix it and watch it go green. The muscle you’re building is recognising the race from the error message, because that’s what you’ll actually meet at work, often with no one to tell you it’s a race.
Check Yourself
Why does a bare depends_on not prevent the app from crashing on a not-ready database?
Because depends_on only controls order of starting — it waits for the dependency’s container to have started, not to be ready. Postgres takes a few seconds after its container starts before it accepts connections, so the app launches into that gap and gets “connection refused.”
What two pieces together actually make the app wait for a ready database?
A healthcheck on the db service (e.g. pg_isready) so Compose can tell when Postgres is truly ready, plus condition: service_healthy on the app’s depends_on so Compose holds the app back until that healthcheck passes.
When do you use build vs image for a service?
Use build: . when the image comes from your own Dockerfile (code you write and change, like app). Use image: postgres:16 when you want a prebuilt published image from a registry (software you don’t write, like the database or cache).
How does the app reach Postgres, and why isn't it an IP address?
By the service name: db:5432. Compose puts every service on one private network and registers each service’s name as a hostname, so services find each other by name. IPs change between runs; names don’t.
Why does db have no ports entry while app does?
ports publishes a container port to your host machine. Only the app needs to be reached from outside (the browser), so only it publishes. The database is reached internally by the app over the private network, and you deliberately don’t expose it to the host.
What is the difference between docker compose down and docker compose down -v?
down stops and removes the containers and the network but keeps named volumes, so your Postgres data survives. down -v also deletes the volumes — wiping the data. Use -v only when you want a clean database.
You changed your app's code but docker compose up shows the old behaviour. Why, and what fixes it?
up reuses the existing image and doesn’t rebuild by default. Run docker compose up --build to force Compose to rebuild the app image from your Dockerfile.
What is compose.override.yml for?
It’s a file Compose merges automatically on top of docker-compose.yml, used for local/dev-only tweaks — like bind-mounting your source for live reload — without polluting the clean base file you commit and ship.
Still Unclear?
Paste any of these to Claude to go deeper:
- “Walk me through exactly what
docker compose updoes step by step for a 3-service stack — network creation, dependency ordering, healthcheck polling — and show me the logs I’d expect to see.” - “Here is my docker-compose.yml [paste it]. My app crashes on boot with ‘connection refused’ to Postgres. Diagnose the race condition and show me the precise healthcheck + depends_on fix.”
- “Explain the difference between
condition: service_started,service_healthy, andservice_completed_successfullyin depends_on, with a SplitEase example of when I’d use each.”
Why AI Can’t Do This For You
AI will happily generate a docker-compose.yml — but it can’t tell from your error log that the flaky “connection refused” is a startup race and not a bad password, a wrong port, or a network typo. That diagnosis — reading the symptom, knowing depends_on waits for started not ready, and choosing the right condition for each dependency — is judgment you build by watching the failure happen with your own eyes. The file is cheap; knowing why it’s wrong at 2am is the skill.
Module done? Add it to today’s tracker