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
Picture the demo that goes wrong. You've dockerized your API — one clean image, one docker run, it works. Then you add a database. Now the ritual is: start Postgres in one terminal, wait, copy its address, start Redis in another, remember the three environment variables that glue them together, then launch the API and hope you did it in the right order. Do that by hand every morning, teach it to a new teammate, and reproduce it identically on a CI runner — and you'll feel exactly why single-container knowledge stops being enough the moment your app grows a second moving part.
Here's the mental shift. In CD1 and CD2 you learned to think of one container as a sealed, reproducible box. A real application is not one box — it's a small team of boxes that have to be hired together, introduced to each other, and started in a sane order. Think of a restaurant: the kitchen, the fridge, the till, and the delivery driver are separate stations, but they only produce a meal when they're in the same building and can shout across the pass. Launching them one at a time from separate street corners is not a restaurant; it's four lonely people. Your API, database, cache, and worker are those stations.
The word that captures each box is service — a single job the app performs (serve requests, store state, cache hot data, grind through background work), packaged as its own container. The common beginner mistake is to cram all of that into one giant container "to keep it simple." It feels simpler for about a day, and then you can't restart the database without killing the API, can't scale the worker independently, and can't swap Postgres 16 for 17 without rebuilding everything. Splitting responsibilities into separate services is what lets each piece fail, restart, scale, and upgrade on its own — which is the entire reason the industry containerizes in the first place.
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
Stop imagining a script and start imagining a recipe. The natural first instinct is to write a shell script — docker run this, then docker run that, with these flags — a list of commands to execute. Compose asks you to do the opposite: describe the end state you want and let it figure out the commands. That difference — telling the tool what instead of how — is called declarative configuration, and it's why a compose file reads like a description of your app rather than a set of instructions.
The mental model is an architect's blueprint versus a builder's to-do list. A to-do list says "pour foundation, then raise walls, then add roof" and breaks the instant one step is out of order. A blueprint just says "here is the finished house" — and anyone can build it, rebuild it, or check that reality matches. Your docker-compose.yml is the blueprint: it names every service, the image or build each one uses, the ports it opens, the environment it needs, and how they connect. Run docker compose up and Compose reads the blueprint and makes reality match it.
The single most important thing this file quietly gives you is networking by name. When Compose starts your services it drops them all onto one private virtual network and runs a tiny internal DNS, so the API can reach the database at the hostname db — literally the service's name — instead of a brittle IP address that changes every restart. This is the mistake that eats hours: newcomers write localhost:5432 in their connection string and get "connection refused," because inside the API's container localhost means the API itself, not the database. Each container has its own private localhost. The address of another service is always its service name. Watch for the @db:5432 in the file below — that one detail is the whole trick, and getting it wrong is the number-one Compose beginner bug.
One more note on the format: compose files are YAML, where indentation is structure. The lines nested under a service name belong to that service; a stray space or a tab instead of spaces will change the meaning or break the parse entirely. Read it like an outline, not like code.
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 up starts them all on one private network where each is reachable by its name (db, not an IP).
Read it left → right: the file you write on the left becomes the running system on the right when you run one command.
- The left box is
docker-compose.yml— a declarative description ("here are my services") rather than step-by-step commands. That's the "blueprint, not to-do list" idea. - The blue arrow is
docker compose up: Compose reads the blueprint and makes reality match it. - The dashed box on the right is the one private network Compose creates. The
api,dbandcacheboxes are the running containers, all on it. - The payoff is the arrow from
apitodband the line...@db:5432: because Compose runs an internal DNS, the api reaches the database by the service namedb— writinglocalhostinstead is the number-one beginner bug.
In short: Declare the stack once; one command runs it on a shared network where services find each other by 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
This is the bug that works on your machine and dies in CI. You add depends_on: [db], run up on your laptop, and it's fine — because your laptop's Postgres happens to boot in a fraction of a second. Then a colleague runs it on a slower machine, or CI runs it in a fresh container with cold disk, and the API face-plants on startup with "connection refused." Nothing changed in your code. What changed is timing, and timing bugs are the cruellest kind: invisible until the one environment where they aren't.
The root cause is a subtle but crucial distinction: started is not the same as ready. When Compose says the db container has "started," it means the process is running — Postgres has been launched. But a database that's just launched is still opening its data files, replaying its write-ahead log, and setting up its listener. For a second or two it's alive but not answering. Think of it like a shop: the owner has arrived and unlocked the door (started), but the till isn't counted and the lights aren't on yet (not ready). A customer who walks in during that gap gets turned away. Your API is that customer, and depends_on in its plain form only waits for the door to unlock — not for the shop to actually open.
The mistake almost everyone makes first is to "fix" this with a sleep 10 in the API's startup — just wait a bit and hope the database is up by then. It's fragile in both directions: too short and it still fails on a slow day; too long and you've wasted ten seconds on every single boot forever. Guessing at time is never the answer. The real fix is to ask the database whether it's ready and only proceed when it says yes. That's what a healthcheck is: a small command Compose runs on the container over and over until it passes, at which point the service is marked healthy. Pair it with the longer depends_on form and condition: service_healthy, and Compose will hold the API back until Postgres genuinely answers — not merely until its container exists.
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.
depends_on launches the api the moment the db container exists — often a second too early. A healthcheck + service_healthy holds it back until the database actually answers.
Two timelines, split by the dashed line: the buggy path on the left, the fix on the right. Read each top → bottom.
- Left (the trap): the amber "db started" box only means the process launched; the grey box shows it is still initializing and not answering. The api connects into that gap and hits the red "crash" box — the "connection refused" you see in CI.
- Right (the fix): after "db started", the
pg_isready?check runs on a loop every few seconds. Only when it passes does the db turn green ("healthy"). - The payoff is the green line
condition: service_healthy: it makes the api wait for that green state, not merely for the container to exist — so it never connects too early.
In short: Gate the api on the db's healthcheck, and timing bugs that "work on my machine" disappear.
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
Here's the horror story that teaches this lesson once and forever. You've been developing all week against your local Postgres container — real users, real orders, real test data you carefully built up. You run docker compose down to stop for the night, up again in the morning, and your database is empty. Every row gone. Nothing was broken; the container did exactly what containers are designed to do — and if you don't understand why, you will lose data that matters someday.
The reason is that a container's own filesystem is disposable by design. A container is like a hotel room: you can rearrange the furniture and drop things on the floor while you're there, but housekeeping wipes it clean the moment you check out. Everything a program writes inside its container lives in a thin, throwaway layer that is destroyed when the container is removed. That's a feature — it's what makes containers reproducible and cheap to recreate — but it means anything you actually want to keep has to live somewhere outside that disposable layer. That somewhere is a volume: storage that Docker manages separately from any single container, so it survives even when the container is deleted and recreated.
There are two flavours, and confusing them is the classic mistake. A named volume (like dbdata) is storage Docker owns and keeps in its own area — you name it, mount it into the container, and Docker takes care of where it physically lives. That's what you use for a database: you don't care where the bytes sit, you just want them to persist. A bind mount is different — it maps a specific folder from your host machine straight into the container, so the two see the same files live. That's perfect for development (edit code on your laptop, see it change inside the container instantly), but it's the wrong tool for a database, because you'd be tying your data's fate to a fragile host path. Rule of thumb: named volume for data you must not lose, bind mount for code you're actively editing.
Which brings back the horror story: it happens when you never declared a volume at all, so the database wrote into that disposable layer and lost it on removal. Note the sharp edge — docker compose down keeps your named volumes, but down -v deletes them. That -v is a loaded gun: safe when you truly want a clean slate, catastrophic when you fat-finger it on real data.
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).
The dashed box is one container; look at what lives inside it versus what it merely points out to.
- The red "writable layer" is the container's own throwaway storage — whatever a program writes here is destroyed when the container is removed. That's the empty-database horror story.
- The green "mount point" is a path inside the container (like
/var/lib/postgresql/data) that is wired to storage outside the container — shown by the two arrows. - The arrows lead to two kinds of outside storage: an amber named volume (Docker-managed, survives
down) for data you must not lose, and a blue bind mount (a folder on your laptop) for code you edit live in dev. - The payoff is the rule on the right: named volume = keep data (the DB); bind mount = live code (dev). Confusing the two is the classic mistake.
In short: Data you can't lose goes in a named volume; code you're editing comes in via a bind mount — never trust the container's own layer.
| 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
Everything so far has been one idea at a time; now watch them click together. Up to this point you've met services, name-based networking, healthchecks, and volumes as separate lessons. But you never ship "a healthcheck" — you ship a system, and the skill that actually matters is seeing how these pieces compose into one coherent stack. This section is where the course stops teaching parts and starts teaching the machine.
The stack below is deliberately production-shaped: an API to serve requests, a background worker to grind through slow jobs, Postgres for durable state, and Redis for hot data. Think of it as a small factory. The API is the front counter taking orders; the worker is the back room doing the heavy lifting so the counter never gets clogged; Postgres is the filing cabinet that must never lose a record; Redis is the sticky-note board for things you look up constantly. Each has its own reason to exist, and the compose file is the factory floor plan that puts them all under one roof and wires them together.
Two patterns in it are worth naming because they're where beginners either level up or trip. The first is one image, two roles: the API and the worker are built from the same image and differ only by the command that launches them. That's not a shortcut — it's how real teams keep a worker and its API perfectly in sync, since they literally share the same code and dependencies. The mistake it prevents is maintaining two near-identical images that silently drift apart. The second is config in one place: instead of scattering passwords and URLs across the YAML, every service reads from a shared .env file via env_file. That keeps secrets out of the committed compose file and means changing a setting is a one-line edit, not a hunt-and-replace. Read the file below not as five unrelated blocks but as one wired system, and notice how every earlier lesson shows up in it.
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.
command); both gate on a health-checked Postgres; Redis and the db keep named volumes; all read one shared .env.
Scan left → right: one image on the left fans out into services, which depend on the datastores on the right; the shared config sits underneath.
- The left blue box is the single image built from your Dockerfile. Two arrows show it becoming two green services —
apiandworker— that differ only by thecommandline. That's the "one image, two roles" pattern. - The arrows to the amber db and Redis cache are
depends_on. Theservice_healthylabel means the api waits for Postgres's healthcheck (the green tick) before starting. - The db and cache each carry a named volume (
dbdata,cachedata) so their data survives restarts. - The payoff is the wide box at the bottom: a single shared
.envfeeds every service, so secrets and URLs live in one place instead of being scattered through the YAML.
In short: One image → two roles, both gated on a healthy, volume-backed database, all configured from one .env — that's a production-shaped 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
The temptation here is obvious, and it's a trap. Dev and production genuinely need different settings — in dev you want your code changes to appear instantly, in prod you want the frozen, built image and a restart policy. So the lazy fix is to keep two whole compose files, docker-compose.dev.yml and docker-compose.prod.yml, each a full copy. It works for a week. Then you add a service to one and forget the other, or change a port in dev that never makes it to prod, and now your two environments have quietly diverged — which is exactly the "works on my machine" disease containers were supposed to cure.
The professional pattern is to have one base file that is the single source of truth, plus a small override that lists only what's different for dev. Think of it like a document and its track-changes: the base is the agreed text, and the override is a short list of edits layered on top. You never restate the whole config — you only say "in dev, also mount the code and turn on auto-reload." Because production is the base file untouched, prod can never accidentally inherit a dev-only convenience, and the two environments stay honest with each other.
Compose makes this almost invisible through a naming convention that surprises people: a file literally named docker-compose.override.yml is loaded automatically and merged on top of the base whenever you run docker compose up. You don't pass it on the command line — Compose finds it by name. That's the magic and also the gotcha: the override is on by default, so dev is what you get for free, and reaching production is the deliberate act of pointing at only the base file with -f docker-compose.yml. Get that mental model right — default is dev, prod is the explicit choice — and the whole pattern stops feeling like magic.
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.
docker-compose.override.yml on top. Prod is the deliberate act of pointing at only the base file, so it can never inherit a dev-only convenience.
Read it top → down: one base file at the top splits into two ways of running it below.
- The top box is
docker-compose.yml— the single source of truth that both environments share. You never duplicate it. - The left green box (dev) is what you get for free: running
docker compose upauto-loadsdocker-compose.override.ymland merges it on top, adding the bind-mount and--reload. Note it says "default". - The right amber box (prod) is the explicit choice:
-f docker-compose.ymlpoints at only the base file, so there's no code mount and no auto-reload — just the stable built image. - The payoff: because prod is the untouched base, it can never accidentally inherit a dev convenience, and the two environments stay honest.
In short: Default = dev (base + override); prod = the deliberate -f base-only — one file, two behaviors, zero duplication.
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
Remember your own worst first day. A new engineer joins, clones the repo, opens a README titled "Local Setup," and finds thirty steps: install this version of Postgres, no wait, that one; create a database with this exact name; install Redis; export nine environment variables; oh, and Mike knows the last two, ask him. A day and a half later they've got it half-working. Multiply that by every hire, every laptop reformat, every "I deleted node_modules and now nothing works," and onboarding friction becomes one of the quiet, expensive taxes on a whole team's time.
This is where Compose stops being a convenience and becomes a leadership tool. The win a tech lead delivers is collapsing that thirty-step README into two commands: git clone and docker compose up. The compose file is the setup instructions — except it's executable, version-controlled, and reviewed like any other code, so it can't rot the way a prose README silently does. When the stack changes, the file changes in the same commit; there's no separate document to forget. New hires are writing real code in minutes instead of days, and "it works on my machine" stops being a sentence anyone says.
But a promise you don't verify is just a hope, and here's the mistake leads make: they assume the compose file is complete because it was complete last month. Services get added, someone comments one out to debug and forgets to restore it, a merge drops a block. The professional move is to make completeness checkable — a tiny automated test that reads the compose file and confirms every service the app needs is actually declared. That's exactly what the runnable check below does, and it's the seed of the kind of guardrail real teams wire into CI so a broken stack fails the build instead of failing a new teammate's first morning.
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 keeps it honest by asserting every required service is declared.
Two approaches sit either side of the dashed line: the painful old way on the left, the Compose way on the right.
- The left red box is the traditional "Local Setup" README — thirty manual steps, tribal knowledge ("ask Mike"), and a day and a half lost. It rots because prose and reality drift apart.
- The right green box is the whole onboarding:
git clonethendocker compose up. The compose file is the setup, so it's executable and reviewed like code. - The payoff is the small box below it:
check_stack()reads the file and confirms every required service is present — the seed of a CI guardrail so a broken stack fails the build, not a new hire's first morning.
In short: Replace a rotting 30-step README with two commands, and add a completeness check so the stack can't quietly go missing a service.
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?