Career OS

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.yml for the SplitEase stack: app, Postgres, Redis
  • Explain the difference between build and image, 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:

KeyWhat it meansUsed for
build: .Build an image from your Dockerfile in this directoryapp — your own code that you change
image: postgres:16Pull a prebuilt image from a registrydb, 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. Only app has it, because only the app is something you reach from outside. db and cache have no ports: 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 mangle 5432: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 -e flag from the manual version.
  • volumesdb mounts the named volume pgdata at Postgres’s data directory /var/lib/postgresql/data. Named volumes are declared once at the bottom under the top-level volumes: 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:

  1. Give db a healthcheck — a command Compose runs on a loop to ask “are you actually ready?” For Postgres that’s pg_isready, the official “can you accept connections?” probe. Until it passes, Compose marks the service starting; once it passes, healthy.
  2. 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 down keeps your volumes, so your data survives a restart. docker compose down -v deletes them. Use -v when you want a clean database; never run it when you care about the data.
  • Changed your app code or Dockerfile? up won’t rebuild by default. Run docker compose up --build to 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.

  1. In your splitease-api repo (the one with the Dockerfile from Docker 03), create docker-compose.yml with the three services from the walkthrough above — app (built from .), db (Postgres 16, named volume, healthcheck), and cache (Redis 7). Give app a depends_on with condition: service_healthy on db.

  2. Bring it up and watch the order:

    docker compose up
    

    You 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.

  3. In a second terminal, confirm the stack is healthy and reachable:

    docker compose ps
    curl http://localhost:8080/actuator/health
    

    ps should show db as healthy and app as running; the health endpoint should return {"status":"UP"} (or hit any real SplitEase endpoint and get a 200).

  4. 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

WhatWhereTime
Compose getting-started walkthrough (multi-service app)docs.docker.com — “Get Started” → Compose45 min
The docker-compose.yml reference (every key, looked up as needed)docs.docker.com — Compose file referenceongoing
Run a multi-container stack in-browser, nothing to installlabs.play-with-docker.com30 min
Your real stackthe SplitEase repo on your machine1 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 up does 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, and service_completed_successfully in 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

Saves your progress on this device.