Week 5: Docker Compose — Multi-Container Apps

Every docker network create / docker run command from Week 4 is about to get replaced by a single YAML file. Docker Compose describes your whole application — every service, network and volume — declaratively, so standing up (or tearing down) a multi-container app becomes one command instead of a page of shell history you hope you remember correctly.

Module 5 of 17 Week 5 of 18 ~3.5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a compose.yaml defining multiple services, networks, volumes and environment variables
  • Control startup ordering with depends_on and healthchecks
  • Stand up and tear down a full app + API + database stack with one command each

1. Your First compose.yaml

A Compose file describes a set of services — each one, roughly, the equivalent of a docker run command, written declaratively:

compose.yaml
services:
  web:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
terminal
docker compose up          # build (if needed) and start every service
docker compose up -d       # same, but detached
docker compose ps          # see what's running
docker compose logs -f web # follow one service's logs
docker compose down        # stop and remove containers, networks (not volumes, by default)

Compose automatically creates a network for the whole project and attaches every service to it — so everything you learned about container-name DNS in Week 4 just works, for free, with zero networking config written by hand.

Compose is for one host

Compose orchestrates multiple containers on a single machine — it's the right tool for local development and small single-server deployments. It is not a substitute for a real orchestrator across multiple machines; that's exactly the gap Kubernetes fills starting Week 7.

2. Services, Networks & Volumes

A real Compose file usually names its networks and volumes explicitly, and every service block maps closely to flags you already know from docker run:

compose.yaml — a service, mapped from docker run flags you know
services:
  api:
    build: ./api               # equivalent of: docker build -t api ./api
    ports:
      - "3000:3000"             # equivalent of: -p 3000:3000
    environment:
      - DATABASE_URL=postgres://postgres:devpw@db:5432/appdb
    networks:
      - backend

  db:
    image: postgres:16          # equivalent of: docker run postgres:16 (no build needed)
    environment:
      - POSTGRES_PASSWORD=devpw
      - POSTGRES_DB=appdb
    volumes:
      - pg-data:/var/lib/postgresql/data
    networks:
      - backend
    # note: no "ports" here — same "don't publish the database" rule from Week 4

networks:
  backend:

volumes:
  pg-data:

Notice that db has no ports section — the same don't-publish-the-database rule from Week 4 applies exactly the same way here. The api service reaches it at hostname db because Compose service names resolve through the same embedded DNS as manually created networks.

Never commit real secrets into compose.yaml

Use an .env file (Compose loads it automatically) or env_file: for anything sensitive, and add .env to your .gitignore. A compose.yaml with a hardcoded production password is a secret leak waiting to happen the moment it's pushed to a public repo.

3. Startup Order & Healthchecks

depends_on alone only controls the order containers are started in — it does not wait for the database to actually be ready to accept connections. That gap causes a classic bug: the API container starts, tries to connect before Postgres has finished initializing, and crashes.

the bug — depends_on alone isn't enough
services:
  api:
    build: ./api
    depends_on:
      - db          # only guarantees "db" is STARTED first, not READY
  db:
    image: postgres:16
the fix — a healthcheck + condition
services:
  api:
    build: ./api
    depends_on:
      db:
        condition: service_healthy   # wait for db's healthcheck to actually pass

  db:
    image: postgres:16
    environment:
      - POSTGRES_PASSWORD=devpw
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

Now api genuinely waits until pg_isready succeeds inside the db container, not just until the container process has started. This same healthcheck concept — "don't just check if it started, check if it's actually ready to do work" — reappears as Kubernetes readiness probes in Week 16.

Or make your app itself resilient

A healthcheck helps at startup, but a robust app should also retry its database connection with backoff on its own — containers restart, networks blip, and "wait once at boot" isn't a complete solution on its own in a real production environment.

4. A Full Stack, One Command

Put it all together: a frontend, an API, a database and a cache, defined once, started with one command:

compose.yaml — the full stack
services:
  frontend:
    build: ./frontend
    ports:
      - "8080:80"
    depends_on:
      - api

  api:
    build: ./api
    environment:
      - DATABASE_URL=postgres://postgres:devpw@db:5432/appdb
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:16
    environment:
      - POSTGRES_PASSWORD=devpw
      - POSTGRES_DB=appdb
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 5

  cache:
    image: redis:7-alpine

volumes:
  pg-data:
terminal
docker compose up -d --build   # rebuild any changed images, start everything
docker compose ps               # four services, all healthy
docker compose down             # tear the whole thing down cleanly
This is the onboarding story every team wants

A new teammate should be able to clone a repo and run docker compose up to get a fully working local environment — frontend, API, database, cache — with zero manual setup steps or "install these seven things first" wiki pages.

5. Hands-on Exercise

Hands-on

Compose-ify your Week 4 two-container app, then add a third service

Convert last week's manually-wired API + database into a proper Compose file, then extend it and prove the healthcheck actually matters.

Part 1 — Convert to Compose:

  1. Write a compose.yaml for your Week 4 API + database setup: two services, a named volume for the database, environment variables for the connection string.
  2. Bring it up with docker compose up -d --build and confirm the app works exactly as it did with manual docker run commands.
  3. Run docker compose down, then docker compose up -d again, and confirm your database data is still there (this proves the named volume survived the teardown).
Hint

docker compose down -v (with the extra -v) additionally removes named volumes — useful for a truly clean slate, but be intentional about when you use it, since it's how you'd lose that persisted data on purpose.

Part 2 — Add a cache and prove the healthcheck matters:

  1. Add a third service, a Redis cache, with no published port, on the same network as your API.
  2. Add a Postgres healthcheck and a depends_on: condition: service_healthy on your API service, as shown in Section 3.
  3. Deliberately break it: remove the healthcheck and condition, and add an artificial delay to your database's startup (or just observe with fresh eyes — Postgres itself takes a few seconds to become ready on a first run with a fresh volume). Watch your API's logs for a connection-refused error at startup.
  4. Restore the healthcheck and condition, tear everything down including volumes, and bring it up fresh again — confirm the connection-refused error no longer happens, and explain in your own words why.
Hint

A fresh Postgres volume runs first-time database initialization, which takes noticeably longer than a normal restart — that's exactly the window where depends_on without a healthcheck condition tends to fail, and it's a great natural reproduction of the bug without needing to fake anything.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why isn't plain depends_on enough to guarantee a database is ready before a dependent service starts?

Plain depends_on only sequences container start order — it starts the database's container process first, but doesn't wait for that process to finish initializing and actually accept connections. A healthcheck plus a condition: service_healthy is needed to wait for genuine readiness, not just a started process.

Q2

Why shouldn't you commit real passwords directly into a compose.yaml file?

compose.yaml is typically committed to version control, so hardcoded secrets end up in Git history — retrievable forever, even if later removed, and especially dangerous in a public repository. Use an .env file (gitignored) or a proper secrets manager instead.

Q3

Does docker compose down remove named volumes by default?

No — by default it only removes containers and networks, leaving named volumes (and their data) intact. You must explicitly pass -v (docker compose down -v) to also remove volumes, which is a deliberate safety default so a routine teardown doesn't silently destroy persisted data.

Q4

Is Docker Compose a substitute for Kubernetes in a multi-machine production deployment?

No. Compose orchestrates multiple containers on a single host — it has no concept of scheduling work across multiple machines, self-healing by rescheduling failed workloads elsewhere, or cluster-wide load balancing. It's excellent for local development and small single-server setups, but a genuinely distributed, resilient deployment needs a real orchestrator like Kubernetes.