AI EngineeringZero to ProductionHome·About·What’s new·Contact
Containers & Deployment · Chapter CD3

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.

⏱️ ~3 hours🧪 10 labs🎯 Beginner→Tech-lead

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.
▶ Runnable companionCode saved under 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.

api your code db (Postgres) state cache (Redis) hot data worker background jobs
🗺️ How to read this diagram

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 api can 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.

you write (declarative) docker-compose.yml services:   api / db / cache the end state you want, not a list of commands docker compose up Compose makes reality match · one shared network + internal DNS api db :5432 cache DATABASE_URL = ...@db:5432 reach the db by its service name, never localhost A compose file is a blueprint, not a script. You declare the services you want; docker compose up starts them all on one private network where each is reachable by its name (db, not an IP).
🗺️ How to read this diagram

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, db and cache boxes are the running containers, all on it.
  • The payoff is the arrow from api to db and the line ...@db:5432: because Compose runs an internal DNS, the api reaches the database by the service name db — writing localhost instead 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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
config · docker-compose.yml
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:
▶ How this works

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.

  1. services: opens the list. api:, db: and cache: are the three services — three separate containers Compose will run together.
  2. Under api: build: . means "build this service's image from the Dockerfile in the current folder". ports: ["8000:8000"] exposes port 8000 so you can open it in a browser (host port : container port).
  3. environment: passes settings in as environment variables. Notice @db:5432 in the DATABASE_URL — db is the service name above, used as a hostname (more on that below).
  4. depends_on: [db, cache] tells Compose to start the database and cache before the api. The db service uses a ready-made image: postgres:16 (no build needed), and volumes: saves its data so it survives restarts.
  5. 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.

shell · one command to run the whole stack
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)
▶ How this works

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.

  1. docker compose up -d builds and starts every service. -d means "detached" — it runs in the background and gives your terminal back instead of filling it with logs.
  2. docker compose ps lists what's running, so you can confirm all services came up.
  3. docker compose logs -f api shows the api service's output; -f "follows" it live, like watching a tail. Swap api for another service name to watch that one.
  4. docker compose exec db psql -U app opens a shell inside the running db container — here the Postgres client — so you can poke at the database.
  5. docker compose down stops and removes the containers. Adding -v also deletes the volumes, which wipes your data — so leave -v off 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.

Services connect by name, not localhostThe API reaches the database at host 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.

plain depends_on — waits only for "started" db started db still initializing (not answering) api connects → crash "connection refused" on boot healthcheck gate — waits for "ready" db started pg_isready? retry every 5s db healthy then api starts safely condition: service_healthy "Started" is not "ready". Plain 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.
🗺️ How to read this diagram

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.

config · wait for actually-ready, not just started
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
▶ How this works

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.

  1. Under db, the healthcheck: block defines the readiness test. test: ["CMD-SHELL", "pg_isready -U app"] runs Postgres's own pg_isready command, which succeeds only once the database is actually accepting connections.
  2. interval: 5s checks every 5 seconds, timeout: 3s gives each check 3 seconds to answer, and retries: 5 means it tries up to 5 times before declaring the service unhealthy.
  3. Under api, depends_on now uses the longer form with condition: service_healthy. That says: don't start the api until the db healthcheck 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".

Python · compute a safe startup order (runs)
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']
▶ How this works

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.

  1. def start_order(deps): takes a dictionary mapping each service to the list of services it needs first. order collects the answer; done remembers which services we've already placed.
  2. visit(s, stack) is a helper that handles one service. stack is the chain of services we're currently in the middle of — if s is already in it, we've gone in a circle, so it raises dependency cycle (A needs B needs A can never start).
  3. 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 we done.add(s) and order.append(s) — guaranteeing dependencies land earlier in the list.
  4. 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 container writable layer gone on removal mount point /var/lib/... · /app named volume (dbdata) Docker-managed · survives down bind mount ( .:/app ) a folder on your host keep data use for the DB live code use in dev Persist outside the disposable layer. Anything the container writes into its own writable layer dies on removal; a named volume (data you must not lose) and a bind mount (code you're editing) live outside it and survive.
🗺️ How to read this diagram

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.

TypeSyntaxUse for
Named volumedbdata:/var/lib/...persistent data (DB) — managed by Docker
Bind mount.:/applive code in dev; host files
tmpfsin-memorysecrets/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.

one built image build: . api serve requests · :8000 worker python -m worker same image, two roles db (Postgres) healthcheck ✓ + dbdata vol cache (Redis) cachedata vol service_healthy shared .env (env_file) → every service one file, one command, one network — the whole system Every earlier lesson, wired into one system. The api and worker share one built image (differing only by command); both gate on a health-checked Postgres; Redis and the db keep named volumes; all read one shared .env.
🗺️ How to read this diagram

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 — api and worker — that differ only by the command line. That's the "one image, two roles" pattern.
  • The arrows to the amber db and Redis cache are depends_on. The service_healthy label 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 .env feeds 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.

config · a complete compose 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: {} }
▶ How this works

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.

  1. api builds from your Dockerfile, exposes port 8000, and reads its settings from a shared .env file via env_file: .env (so secrets live in one place, not in the YAML).
  2. Its depends_on waits for db to be service_healthy and cache to be service_started. restart: unless-stopped tells Compose to relaunch the container if it crashes.
  3. worker reuses the same built image as the api but overrides the startup command with command: python -m worker — one image, two roles. This is a common, money-saving pattern.
  4. db (Postgres) keeps its data in the dbdata volume and has the same healthcheck you saw earlier; cache (Redis) keeps its data in cachedata.
  5. 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.yml base · single source of truth dev (default) base + docker-compose.override.yml auto-merged: bind-mount code + --reload just run: docker compose up prod (explicit) -f docker-compose.yml only built image · no mount · restart policy the deliberate choice One base file, a thin dev override. Dev is the default — Compose auto-merges 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.
🗺️ How to read this diagram

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 up auto-loads docker-compose.override.yml and 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.yml points 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.

config · dev override (auto-merged)
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
▶ How this works

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.

  1. The filename matters: docker-compose.override.yml is loaded automatically alongside docker-compose.yml when you run docker compose up in dev. You don't name it explicitly.
  2. 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.
  3. environment: { ENV: dev } flags this as a dev run, and the command: line starts the server with --reload, so it restarts itself whenever you save a file.
  4. The comment at the bottom shows production: run docker compose -f docker-compose.yml up -d to 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.

the old way — a prose README 30-step "Local Setup" install Postgres, Redis · 9 env vars "ask Mike for the last two" ~1.5 days · silently rots the Compose way git clone docker compose up productive in minutes · the file IS the setup check_stack() → every service up? Onboarding collapses from days to two commands. The compose file is the setup — executable and version-controlled, so it can't rot like a prose README. A tiny check_stack keeps it honest by asserting every required service is declared.
🗺️ How to read this diagram

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 clone then docker 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.

Python · verify the dev stack is complete (runs)
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'])
▶ How this works

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.

  1. check_stack(compose_yaml, required) takes the compose file's text and a list of service names you expect to find.
  2. 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 under services:. present is the set it found.
  3. 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.
  4. The two print calls test it against a sample compose file that has api, db and cache but no worker.

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.

This is the DF reproducibility promise, for the whole stackDF5 made one app reproducible with a venv; Compose makes the whole system reproducible with one command. A lead who standardizes this eliminates a huge class of onboarding and 'works on my machine' pain.

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_order and verify completeness with check_stack
  • Confirm data survives compose down && up but is wiped by down -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.

Exercise 1 · Your first docker-compose.ymlBeginner

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 with ports
  • Reach the database at host db (the service name) in the DATABASE_URL, not localhost
  • Use ready-made images postgres:16 and redis:7 for 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).

Exercise 2 · Fix the startup-ordering trapIntermediate

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 healthcheck to the db that runs pg_isready -U app
  • Tune the check with interval, timeout, and retries
  • Use the long-form depends_on on the api with condition: 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".

Exercise 3 · Topological startup orderAdvanced

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 cycle error
  • 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.

Exercise 4 · The full realistic stackExpert

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 } and cache: { condition: service_started }
  • Reuse the built image for the worker, overriding only command: (e.g. python -m worker)
  • Add restart: unless-stopped to 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.

Exercise 5 · Dev/prod split with an overrideProfessional

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.yml so 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.

Exercise 6 · Verify the stack is completeIndustry scenario

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

✓ Knowledge check

In a Compose file, how does the API reach the database, and what makes that work?

Show answer
It connects by service name as a hostname -- e.g. DATABASE_URL uses @db:5432 where db is the service name -- not localhost. Compose runs an internal DNS on a shared network so each service is reachable by its name.
✓ Knowledge check

What is the startup-ordering trap with depends_on, and how do you fix it?

Show answer
depends_on only waits for a container to start, not to be ready -- so Postgres can be 'started' while still initializing and the API crashes connecting on boot. The fix is a healthcheck (e.g. pg_isready) on the db plus depends_on with condition: service_healthy, so the API starts only after the db actually passes its healthcheck.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in