Multi-service apps with Compose
Real apps are several containers. Compose defines API + Postgres + Redis + worker in one file — wired by name, health-gated, with volumes and dev/prod overrides — plus dependency ordering in Python.
Learning objectives
- Explain why multi-container apps need orchestration.
- Define and wire app + db + cache with Compose.
- Handle startup ordering, health, volumes, and config.
- Split dev/prod and model service dependencies in Python.
code/cd3-compose/. Python runs offline; configs are ready to use.1 · Real apps are several services essential
A real app is your API plus a database plus often a cache and a worker. Running and wiring those by hand (right order, networking, env) is error-prone. docker-compose declares the whole stack in one file and runs it with one command.
This picture is the whole idea of the lesson in one row: a real app is not one program but several small programs (called services) running side by side. Compose's job is to start them all and let them talk to each other.
- api — your code is the service you write: the web server that answers requests. It is the front door of the app.
- db (Postgres) is the database — it stores state (users, orders, anything that must survive). Postgres is just a popular database program.
- cache (Redis) keeps hot data — a fast, in-memory store for things you look up constantly, so you don't hit the slower database every time.
- worker — background jobs does slow work off to the side (sending emails, processing files) so the
apican stay fast and responsive. - How to read the arrows: they show that the pieces are wired together into one system. The point is not a strict left-to-right flow — it's that Compose runs all four at once and connects them, instead of you launching four programs by hand.
In short: A modern app is a small team of programs, not a single one. Compose is the one file that hires the whole team and puts them in the same room so they can talk.
2 · Your first compose file essential
Each service is a container. Compose builds/pulls each, puts them on a shared network, and lets them reach each other by service name.
docker-compose.ymlservices:
api:
build: . # build from local Dockerfile
ports: ["8000:8000"]
environment:
DATABASE_URL: postgresql://app:secret@db:5432/app # 'db' = the service name
REDIS_URL: redis://cache:6379
depends_on: [db, cache]
db:
image: postgres:16
environment: { POSTGRES_USER: app, POSTGRES_PASSWORD: secret, POSTGRES_DB: app }
volumes: ["dbdata:/var/lib/postgresql/data"] # persist across restarts
cache:
image: redis:7
volumes:
dbdata:
This is your first compose file — one YAML file that describes an entire app. Under services: you list each container you want; Compose reads this and starts them all. Indentation is meaningful in YAML: the lines indented under a name belong to that service.
services:opens the list.api:,db:andcache:are the three services — three separate containers Compose will run together.- Under
api:build: .means "build this service's image from theDockerfilein the current folder".ports: ["8000:8000"]exposes port 8000 so you can open it in a browser (host port : container port). environment:passes settings in as environment variables. Notice@db:5432in theDATABASE_URL—dbis the service name above, used as a hostname (more on that below).depends_on: [db, cache]tells Compose to start the database and cache before the api. Thedbservice uses a ready-madeimage: postgres:16(no build needed), andvolumes:saves its data so it survives restarts.- At the bottom, the top-level
volumes: dbdata:declares the named storage the database uses. Declaring it here is what makes the data outlive the container.
What the output means: Nothing runs yet — this is a description. It defines a three-service app (web api + Postgres database + Redis cache) ready to launch with one command.
Try this: Read it top to bottom and list the three service names out loud. Then find where the api gets the database's address — that @db:5432 is the whole trick of service-name networking.
compose.shdocker compose up -d # build + start everything, detached
docker compose ps # what's running
docker compose logs -f api # follow one service's logs
docker compose exec db psql -U app # shell into a service
docker compose down # stop + remove (add -v to wipe volumes/data)
These are the everyday commands for running the app you just described. Each starts with docker compose, which reads the docker-compose.yml in the current folder and acts on all the services at once.
docker compose up -dbuilds and starts every service.-dmeans "detached" — it runs in the background and gives your terminal back instead of filling it with logs.docker compose pslists what's running, so you can confirm all services came up.docker compose logs -f apishows theapiservice's output;-f"follows" it live, like watching a tail. Swapapifor another service name to watch that one.docker compose exec db psql -U appopens a shell inside the runningdbcontainer — here the Postgres client — so you can poke at the database.docker compose downstops and removes the containers. Adding-valso deletes the volumes, which wipes your data — so leave-voff unless you truly want a clean slate.
What the output means: After up -d the whole app is running in the background; ps and logs let you inspect it, and down tears it back down.
Try this: Run up -d, then ps to see three services, then down (without -v) and up -d again — your database data should still be there because the volume survived.
db (the service name), not localhost. Compose runs an internal DNS so each service is reachable by name — which is why DATABASE_URL uses @db:5432.3 · The startup-ordering trap intermediate
depends_on controls start order but NOT readiness — Postgres's container can be "started" while the database is still initializing, so your API crashes connecting on boot. The fix is a healthcheck + condition: service_healthy.
health.yml db:
image: postgres:16
environment: { POSTGRES_USER: app, POSTGRES_PASSWORD: secret, POSTGRES_DB: app }
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"] # is the DB accepting connections?
interval: 5s
timeout: 3s
retries: 5
api:
depends_on:
db:
condition: service_healthy # start api only AFTER db passes its healthcheck
This fixes a classic trap: depends_on waits for a container to start, but a database can be "started" and still not ready to answer queries — so the api connects too early and crashes. The fix is a healthcheck: a little test Compose runs until the service is truly ready.
- Under
db, thehealthcheck:block defines the readiness test.test: ["CMD-SHELL", "pg_isready -U app"]runs Postgres's ownpg_isreadycommand, which succeeds only once the database is actually accepting connections. interval: 5schecks every 5 seconds,timeout: 3sgives each check 3 seconds to answer, andretries: 5means it tries up to 5 times before declaring the service unhealthy.- Under
api,depends_onnow uses the longer form withcondition: service_healthy. That says: don't start the api until thedbhealthcheck actually passes — not merely until its container exists.
What the output means: With this in place, Compose holds the api back until Postgres reports healthy, so the api never crashes trying to connect to a database that isn't ready yet.
Try this: Picture removing the healthcheck: the api would launch the instant the db container appears, often a second too early, and fail on boot. The healthcheck is the difference between "started" and "ready".
start_order.pydef start_order(deps):
"""deps: service -> [needs]. Topological sort; raises on a cycle."""
order, done = [], set()
def visit(s, stack):
if s in stack: raise ValueError(f"dependency cycle at {s}")
if s in done: return
for d in deps.get(s, []): visit(d, stack | {s})
done.add(s); order.append(s)
for s in deps: visit(s, set())
return order
print(start_order({"api": ["db", "cache"], "worker": ["db"], "db": [], "cache": []}))
['db', 'cache', 'api', 'worker']
This small Python program answers "in what order must these services start?" given who depends on whom. It's the same reasoning depends_on does, written out so you can see it. The technique is a topological sort: order things so every dependency comes before the thing that needs it.
def start_order(deps):takes a dictionary mapping each service to the list of services it needs first.ordercollects the answer;doneremembers which services we've already placed.visit(s, stack)is a helper that handles one service.stackis the chain of services we're currently in the middle of — ifsis already in it, we've gone in a circle, so it raisesdependency cycle(A needs B needs A can never start).- Before adding a service, the
for d in deps.get(s, [])loop visits every service it depends on first. Only after those are placed do wedone.add(s)andorder.append(s)— guaranteeing dependencies land earlier in the list. - The final
for s in deps: visit(s, set())kicks the process off for every service, then returns the safe start order.
What the output means: It prints ['db', 'cache', 'api', 'worker']: the database and cache come first (nothing depends on them yet), then the api (needs both), then the worker (needs the db). Every service appears after the things it relies on.
Try this: Add a fake cycle — set "db": ["api"] so db needs api and api needs db — and run it. You'll get the dependency cycle error, which is exactly the mistake a topological sort is designed to catch.
4 · Volumes — persist and share data intermediate
Containers are ephemeral; their writable layer vanishes on removal. Named volumes persist data (your database) beyond the container. Bind mounts map a host folder in (great for live-reloading code in dev).
| Type | Syntax | Use for |
|---|---|---|
| Named volume | dbdata:/var/lib/... | persistent data (DB) — managed by Docker |
| Bind mount | .:/app | live code in dev; host files |
| tmpfs | in-memory | secrets/scratch that must not hit disk |
5 · Advanced — a full realistic stack advanced
Put it together: API + Postgres + Redis + a background worker, with health gating, volumes, env files, and a network. This is a production-shaped local stack.
docker-compose.full.ymlservices:
api:
build: .
ports: ["8000:8000"]
env_file: .env
depends_on:
db: { condition: service_healthy }
cache: { condition: service_started }
restart: unless-stopped
worker:
build: .
command: python -m worker # same image, different entrypoint
env_file: .env
depends_on:
db: { condition: service_healthy }
restart: unless-stopped
db:
image: postgres:16
env_file: .env
volumes: ["dbdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 5
cache:
image: redis:7
volumes: ["cachedata:/data"]
volumes: { dbdata: {}, cachedata: {} }
This is the realistic, put-it-all-together stack: four services — api, worker, db, cache — with health gating, saved data, and shared config. It combines every idea from the lesson into one file you'd actually run locally.
apibuilds from your Dockerfile, exposes port 8000, and reads its settings from a shared.envfile viaenv_file: .env(so secrets live in one place, not in the YAML).- Its
depends_onwaits fordbto beservice_healthyandcacheto beservice_started.restart: unless-stoppedtells Compose to relaunch the container if it crashes. workerreuses the same built image as the api but overrides the startup command withcommand: python -m worker— one image, two roles. This is a common, money-saving pattern.db(Postgres) keeps its data in thedbdatavolume and has the samehealthcheckyou saw earlier;cache(Redis) keeps its data incachedata.- The bottom line
volumes: { dbdata: {}, cachedata: {} }declares both named volumes so the database and cache data persist across restarts.
What the output means: A production-shaped app on your laptop: web api + background worker + database + cache, all wired by name, started in the right order, with data that survives restarts.
Try this: Spot the two services that share an image (api and worker) and the one line that makes them behave differently. Then find every service that saves data and match it to a name in the bottom volumes block.
6 · Professional — dev vs prod overrides professional
Dev and prod differ: dev mounts code for live reload; prod uses the built image, no mount, restart policies. Compose overrides keep both clean from one base file.
override.yml# docker-compose.override.yml (loaded automatically in dev)
services:
api:
volumes: [".:/app"] # live-mount code -> edits reflect without rebuild
environment: { ENV: dev }
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
# prod uses ONLY the base file (built image, no mount, restart policy):
# docker compose -f docker-compose.yml up -d
Dev and production want different setups: while coding you want your edits to appear instantly; in production you want the frozen, built image. Compose lets you keep a small override file with just the dev-only differences, layered on top of the base file.
- The filename matters:
docker-compose.override.ymlis loaded automatically alongsidedocker-compose.ymlwhen you rundocker compose upin dev. You don't name it explicitly. volumes: [".:/app"]is a bind mount: it maps your current project folder into the container at/app. Now editing a file on your machine changes it inside the container instantly — no rebuild.environment: { ENV: dev }flags this as a dev run, and thecommand:line starts the server with--reload, so it restarts itself whenever you save a file.- The comment at the bottom shows production: run
docker compose -f docker-compose.yml up -dto use only the base file — no code mount, no auto-reload, just the built image.
What the output means: In dev, Compose merges base + override so you get live-reloading code; in prod, you point at only the base file and get the stable, built version. One codebase, two behaviors.
Try this: Notice you never duplicate the whole config — the override only lists what changes for dev. That's why the base file stays clean and production-ready.
7 · Tech-lead — one command onboards the team tech-lead
A lead's win: git clone && docker compose up spins up the entire app — no "install Postgres, then Redis, then set these 9 env vars" README. New hires are productive in minutes. Verify the stack is complete with a check like this.
check_stack.pyimport re
def check_stack(compose_yaml, required):
present = set(re.findall(r"^ (\w+):", compose_yaml, re.M))
missing = [s for s in required if s not in present]
return (not missing), missing
compose = """services:
api:
build: .
db:
image: postgres:16
cache:
image: redis:7"""
print("has api+db+cache:", check_stack(compose, ["api","db","cache"]))
print("needs a worker too:", check_stack(compose, ["api","db","cache","worker"]))
has api+db+cache: (True, [])
needs a worker too: (False, ['worker'])
This runnable helper answers "does my compose file actually contain the services it should?" — a quick sanity check a tech lead might run so a teammate's stack isn't missing a piece. It reads the YAML as plain text and looks for the service names.
check_stack(compose_yaml, required)takes the compose file's text and a list of service names you expect to find.re.findall(r"^ (\w+):", compose_yaml, re.M)uses a regular expression to grab every name indented by exactly two spaces followed by a colon — i.e. the service names underservices:.presentis the set it found.missing = [s for s in required if s not in present]lists any required service that's absent. The function returns(not missing, missing): a True/False "is it complete?" plus the list of what's missing.- The two
printcalls test it against a sample compose file that hasapi,dbandcachebut noworker.
What the output means: First line prints (True, []) — all of api+db+cache are present. Second prints (False, ['worker']) — the stack is incomplete because worker is missing. The empty vs non-empty list tells you exactly what to add.
Try this: Add worker: to the sample compose string and re-run — the second line should flip to (True, []). This is a tiny version of the automated checks real teams put in CI.
Exercise CD3.1 — Build a real multi-service stack
Context: Assembling and operating a real multi-service stack — and confirming its data actually persists — is the core skill Compose exists to give you. Proving what survives down versus down -v is where the volume model becomes real.
Your task: Compose API + Postgres + Redis + a worker: wire by service name, persist the DB with a volume, add a healthcheck with a service_healthy gate, and a dev override that live-mounts code.
Requirements:
- Wire all four services by name on Compose's shared network
- Persist the database with a named volume and gate the api on the db's healthcheck
- Add a dev override that bind-mounts code for auto-reload
- Reason about boot with
start_orderand verify completeness withcheck_stack - Confirm data survives
compose down && upbut is wiped bydown -v
💡 Hint: Named volumes outlive the container; that's why down keeps your database while down -v deletes it — test both to see the difference.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A real app is rarely one program — it's an API plus a database plus a cache, wired together. Compose declares that whole stack in one file and runs it with one command instead of launching containers by hand.
Your task: Write your first docker-compose.yml defining three services — an api built from the local Dockerfile, a db (Postgres), and a cache (Redis) — with the api reaching the database by service name.
Requirements:
- List all three services under
services: - Build the api with
build: .and publish port 8000 withports - Reach the database at host
db(the service name) in theDATABASE_URL, notlocalhost - Use ready-made images
postgres:16andredis:7for db and cache - Declare a named volume so the database data persists across restarts
💡 Hint: Compose runs an internal DNS on a shared network, so each service is reachable by its name — that @db:5432 hostname is the whole trick.
Show solution
Under services:, list each container. The api reaches the db at host db (the service name), not localhost, because Compose runs an internal DNS on a shared network.
services:
api:
build: .
ports: ["8000:8000"]
environment:
DATABASE_URL: postgresql://app:secret@db:5432/app # 'db' = the service name
REDIS_URL: redis://cache:6379
depends_on: [db, cache]
db:
image: postgres:16
environment: { POSTGRES_USER: app, POSTGRES_PASSWORD: secret, POSTGRES_DB: app }
volumes: ["dbdata:/var/lib/postgresql/data"] # persist across restarts
cache:
image: redis:7
volumes:
dbdata:
Run the whole stack with one command: docker compose up -d, inspect with docker compose ps, tear down with docker compose down (add -v only to wipe data).
Context: depends_on waits for a container to start, but a database can be 'started' while still initializing — so the api connects too early and crashes on boot. The fix separates 'started' from 'actually ready'.
Your task: Fix the startup-ordering trap: add a healthcheck to the db and gate the api on it so the api starts only once the database is truly ready.
Requirements:
- Add a
healthcheckto the db that runspg_isready -U app - Tune the check with
interval,timeout, andretries - Use the long-form
depends_onon the api withcondition: service_healthy - Explain that this makes the api wait for the healthcheck to pass, not just for the container to exist
💡 Hint: pg_isready succeeds only when the database actually accepts connections — that is the difference between 'started' and 'ready'.
Show solution
depends_on controls start order but not readiness — Postgres can be "started" while still initializing. The fix is a healthcheck plus condition: service_healthy.
db:
image: postgres:16
environment: { POSTGRES_USER: app, POSTGRES_PASSWORD: secret, POSTGRES_DB: app }
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"] # is the DB accepting connections?
interval: 5s
timeout: 3s
retries: 5
api:
depends_on:
db:
condition: service_healthy # start api only AFTER db passes its healthcheck
pg_isready succeeds only once the database actually accepts connections, so Compose holds the api back until the db reports healthy — the difference between "started" and "ready".
Context: The ordering Compose does for you is a topological sort under the hood. Writing it yourself makes the dependency logic explicit and shows how a cycle (A needs B needs A) can never start.
Your task: Compute a safe service startup order in Python with a topological sort over a service -> [needs] map, raising an error if there is a dependency cycle.
Requirements:
- Take a dict mapping each service to the list of services it needs first
- Visit every dependency of a service before placing the service itself
- Track the current chain so a service reappearing in it raises a
dependency cycleerror - Skip already-placed services so shared dependencies aren't repeated
- Return an order where every service appears after everything it relies on (e.g. db, cache, api, worker)
💡 Hint: A depth-first visit that recurses into dependencies before appending the node, plus a stack set to catch a service that revisits itself, is the whole sort.
Show solution
Visit each service's dependencies before placing the service itself; track the current chain to detect cycles.
def start_order(deps):
"""deps: service -> [needs]. Topological sort; raises on a cycle."""
order, done = [], set()
def visit(s, stack):
if s in stack:
raise ValueError(f"dependency cycle at {s}")
if s in done:
return
for d in deps.get(s, []):
visit(d, stack | {s})
done.add(s)
order.append(s)
for s in deps:
visit(s, set())
return order
print(start_order({"api": ["db", "cache"], "worker": ["db"], "db": [], "cache": []}))
# -> ['db', 'cache', 'api', 'worker']
Every service lands after the things it relies on. Set "db": ["api"] to create a cycle and it raises dependency cycle — exactly what the sort is designed to catch.
Context: A production-shaped local stack is more than three services: it adds a background worker, health gating, restart policies, and shared config. The money-saving trick is one image serving two roles.
Your task: Build the full realistic stack in one compose file: api, a worker reusing the same image with a different command, db (Postgres, health-gated + volume), and cache (Redis, volume), all reading config from a shared .env.
Requirements:
- Share config across services with
env_file: .env - Gate the api on
db: { condition: service_healthy }andcache: { condition: service_started } - Reuse the built image for the worker, overriding only
command:(e.g.python -m worker) - Add
restart: unless-stoppedto the long-running services - Declare named volumes for both db and cache so their data survives restarts
💡 Hint: The worker and api are the same image; only the command: line makes them behave differently — one build, two roles.
Show solution
Combine health gating, restart policies, named volumes, and the one-image-two-roles pattern (the worker overrides only command:).
services:
api:
build: .
ports: ["8000:8000"]
env_file: .env
depends_on:
db: { condition: service_healthy }
cache: { condition: service_started }
restart: unless-stopped
worker:
build: .
command: python -m worker # same image, different entrypoint
env_file: .env
depends_on:
db: { condition: service_healthy }
restart: unless-stopped
db:
image: postgres:16
env_file: .env
volumes: ["dbdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 5
cache:
image: redis:7
volumes: ["cachedata:/data"]
volumes: { dbdata: {}, cachedata: {} }
A production-shaped app on your laptop: web api + background worker + database + cache, wired by name, started in order, with data that survives restarts.
Context: Dev wants your edits to appear instantly; prod wants the frozen, built image. Compose overrides keep both behaviors from one base file without duplicating config.
Your task: Keep dev and prod clean from one base file using a Compose override: write the dev-only docker-compose.override.yml that live-mounts code for auto-reload, and give the prod command that ignores it.
Requirements:
- Name the file
docker-compose.override.ymlso it loads automatically in dev - Bind-mount the project folder with
volumes: [".:/app"]for live edits - Flag the dev run (e.g.
ENV: dev) and start the server with--reload - List only what changes for dev — never duplicate the whole config
- Run prod against only the base file:
docker compose -f docker-compose.yml up -d
💡 Hint: In dev Compose merges base + override automatically; pointing at only the base file with -f gives you the stable, built image with no mount or reload.
Show solution
docker-compose.override.yml is loaded automatically in dev alongside the base file. It lists only what changes: a bind mount and a reload command.
# docker-compose.override.yml (loaded automatically in dev)
services:
api:
volumes: [".:/app"] # live-mount code -> edits reflect without rebuild
environment: { ENV: dev }
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
In dev, Compose merges base + override so you get live-reloading code. In prod, point at only the base file to get the stable, built image with no code mount:
docker compose -f docker-compose.yml up -d
One codebase, two behaviors — the override never duplicates the whole config, so the base file stays production-ready.
Context: A tech lead's win is git clone && docker compose up spinning up the entire app so new hires are productive in minutes. A small completeness check catches a stack that's missing a service before it wastes someone's afternoon.
Your task: As a tech lead who wants git clone && docker compose up to onboard the whole team, write a Python check that verifies a compose file contains all required services and reports any that are missing.
Requirements:
- Take the compose file text and a list of required service names
- Extract the service names (indented two spaces, ending in a colon) with a regex
- Diff the required list against what's present to find any missing
- Return a completeness boolean plus the list of missing services
- Demonstrate a stack that has api+db+cache but is missing
worker
💡 Hint: Read the YAML as plain text and match ^ (\w+): in multiline mode to grab the service names; the empty-vs-nonempty missing list tells you exactly what to add.
Show solution
Read the YAML as text and pull out the service names (indented two spaces, ending in a colon), then diff against the required list.
import re
def check_stack(compose_yaml, required):
present = set(re.findall(r"^ (\w+):", compose_yaml, re.M))
missing = [s for s in required if s not in present]
return (not missing), missing
compose = """services:
api:
build: .
db:
image: postgres:16
cache:
image: redis:7"""
print("has api+db+cache:", check_stack(compose, ["api","db","cache"]))
print("needs worker too:", check_stack(compose, ["api","db","cache","worker"]))
# -> (True, []) then (False, ['worker'])
The empty vs non-empty list tells you exactly what to add. This is a tiny version of the automated completeness checks real teams put in CI so a new hire's stack is never missing a piece — Compose makes the whole system reproducible with one command.
✓ Checkpoint — you can move on when you can…
- Explain why multi-service apps need Compose.
- Wire services by name with volumes and env.
- Fix the startup-ordering trap with healthchecks.
- Split dev/prod and make one command onboard the team.
Knowledge check check yourself
In a Compose file, how does the API reach the database, and what makes that work?
Show answer
What is the startup-ordering trap with depends_on, and how do you fix it?