AI EngineeringZero to ProductionHome·About·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

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

Each service is a container. Compose builds/pulls each, puts them on a shared network, and lets them reach each other by service 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_URLdb 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

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.

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

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

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

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.

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

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.

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

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.

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