Week 4: Docker Compose & Multi-Container Apps

In Week 3 you ran an app container and a database container by hand, wiring them together with a manually created network and a couple of long docker run commands. That approach doesn't scale past two containers. This week replaces it with Docker Compose: one declarative YAML file that defines an entire multi-service app and brings it up or down with a single command. The docker-compose.yml you write here is the direct ancestor of the Kubernetes manifests you'll write in Week 11 — same idea of declaring desired state, different orchestrator underneath — and it's exactly what your CI pipeline in Week 6 will spin up to run integration tests against a real database.

Module 4 of 22 Week 4 of 26 ~3–4 Hours Hands-on Exercise Included

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

  • Read and write a docker-compose.yml for a multi-service application
  • Bring up an app + database + cache stack with docker compose up and manage it as a unit
  • Use service dependencies, Compose networks, and named volumes to keep services connected and data persistent

1. Anatomy of a docker-compose.yml

A Compose file declares a set of services — each roughly corresponding to one docker run command you wrote by hand last week — plus the networks and volumes they share. Docker Compose reads it and creates, starts, connects, and tears down every piece as a single unit.

docker-compose.yml
services:
  web:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:app@db:5432/appdb
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    networks:
      - app-net

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - app-net

  cache:
    image: redis:7-alpine
    networks:
      - app-net

networks:
  app-net:

volumes:
  pgdata:

Every top-level key maps directly to something you already know from Week 3: build is the Week 3 Dockerfile, ports is -p host:container, environment is -e, and the top-level networks:/volumes: blocks declare resources that Compose creates for you instead of you running docker network create and docker volume create by hand.

Never hardcode secrets in the Compose file

The plaintext password above is fine for a local Postgres container that never leaves your machine, but the same pattern in a file committed to Git is a real leak. Use an .env file (add it to .gitignore) or Compose's env_file: key for anything resembling a real credential, even in development — habits you build here carry straight into the CI secrets you'll configure in Week 6.

2. Running a Multi-Service App

With the file above saved as docker-compose.yml, the entire stack — app, database, and cache — comes up with one command:

terminal
docker compose up              # build/pull images and start every service, attached
docker compose up -d           # same, but detached (runs in the background)
docker compose up --build      # force a rebuild of any service with a "build:" key
docker compose ps              # list services and their status
docker compose logs -f web     # stream logs for a single service
docker compose logs -f         # stream logs for every service, interleaved

Compose prefixes every log line with the service name, so a multi-service app's combined log stream is still readable — you can see the app container's request log and the database's query log side by side without losing track of which container produced which line.

terminal
docker compose down             # stop and remove containers, and the default network
docker compose down -v          # also remove named volumes -- deletes persisted data!
docker compose restart web      # restart a single service without touching the others
docker compose exec web sh      # shell into a running service, same idea as docker exec

Notice docker compose down alone leaves your named volumes intact — a deliberate safety default, since tearing down a stack for a code change shouldn't silently delete a development database. Only the explicit -v flag destroys volume data.

docker compose, not docker-compose

Modern Docker ships Compose as a plugin (docker compose, two words, no hyphen), replacing the older standalone docker-compose Python tool. Both accept nearly the same commands, but if you see a tutorial using the hyphenated form, know it's the legacy tool — the plugin form is what current Docker installs and CI runners provide by default.

3. Service Dependencies & Compose Networks

depends_on controls start order, not readiness — by default, Compose only waits for a dependency's container to start, not for the service inside it to actually be ready to accept connections. A plain Postgres container reports "started" the instant its process launches, well before it's actually accepting queries, which is exactly the kind of race condition that makes an app crash on its first connection attempt right after docker compose up.

The healthcheck block on the db service and the condition: service_healthy under web's depends_on fix this properly: Compose runs pg_isready every 5 seconds and won't consider db healthy — and won't start web — until Postgres is actually ready to accept connections.

terminal — inspecting health state
$ docker compose ps
NAME              IMAGE                COMMAND                  STATUS
myapp-db-1        postgres:16-alpine   "docker-entrypoint.s…"   Up 12 seconds (healthy)
myapp-cache-1     redis:7-alpine       "docker-entrypoint.s…"   Up 12 seconds
myapp-web-1       myapp-web            "node dist/server.js"    Up 8 seconds

Compose creates a single default network for the whole stack (or, as shown above, you can name one explicitly with a top-level networks: block) and gives every service DNS resolution by its service name — the same name-based discovery you set up manually with docker network create in Week 3, except Compose wires it up automatically for every service listed in the file. Inside the web container, the hostname db and cache simply resolve, no IP addresses or manual network commands required.

Add a healthcheck to every stateful dependency

Any service another service depends on for correctness — a database, a message queue, a cache your app can't function without — should have a real healthcheck, not just rely on depends_on's default start-order behavior. It's a small addition that eliminates an entire class of flaky "works on retry" bugs, both locally and in the CI pipeline you'll build in Week 6.

4. Named Volumes & Persistence

The top-level volumes: block declares named volumes exactly like docker volume create did in Week 3, and a service references one under its own volumes: key with the same name:path syntax you used with docker run -v. Compose creates the volume automatically the first time the stack comes up if it doesn't already exist.

docker-compose.yml — volumes section, expanded
services:
  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data   # named volume: survives "compose down"
  web:
    build: .
    volumes:
      - ./src:/app/src                    # bind mount: live-reload during local dev

volumes:
  pgdata:
    # empty block = Compose manages this volume with default settings

This mirrors the distinction from Week 3 exactly: the database gets a named volume because its data must outlive any single container recreation, while the app service can use a bind mount to your local ./src directory purely for development convenience — editing a file on your host shows up inside the running container immediately, without a rebuild.

terminal
docker volume ls                       # see volumes Compose created, prefixed with the project name
docker volume inspect myapp_pgdata     # where it actually lives on disk, and its driver
docker compose down                    # containers gone, pgdata volume untouched
docker compose up -d                   # new db container, same pgdata volume -- same data
Volume names are project-prefixed

Compose prefixes every resource it creates with the project name — normally the directory the docker-compose.yml lives in — so pgdata in the file becomes myapp_pgdata on disk. If two Compose projects both declare a volume called pgdata from different directories, they get separate, non-colliding volumes automatically; this is also why moving or renaming the project directory can silently point you at a "new," empty volume instead of the old one.

5. Hands-on Exercise

Hands-on

Compose a three-service app with a real healthcheck

Rebuild last week's manually networked app + database setup as a single declarative Compose file, and add a cache service alongside it.

Requirements:

  1. Reuse the multi-stage Dockerfile from Week 3's exercise (or write a small one) for a web service that reads an environment variable for its database connection string.
  2. Write a docker-compose.yml defining three services: web (built from your Dockerfile), db (postgres:16-alpine with a named volume), and cache (redis:7-alpine).
  3. Add a healthcheck to db using pg_isready, and make web's depends_on use condition: service_healthy for the database.
  4. Put all three services on one explicitly named network and confirm from inside web (via docker compose exec web sh) that both db and cache resolve by hostname.
  5. Run docker compose up -d, confirm all three services show as healthy/running with docker compose ps, then run docker compose down followed by docker compose up -d again and confirm your database data persisted.
  6. Finally, run docker compose down -v and confirm the named volume — and the data in it — is actually gone, so you've seen both behaviors deliberately.
Hint

If web crashes on startup with a connection-refused error, check whether you actually wired condition: service_healthy under depends_on — a bare depends_on: [db] list only waits for the container to start, not for Postgres inside it to be ready, which is the exact race condition this section covers.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Your web service lists depends_on: [db] with no healthcheck, and it still occasionally crashes with a connection-refused error right after docker compose up. Why?

Plain depends_on only guarantees the dependency's container has started, not that the service inside it is actually ready to accept connections — Postgres reports "started" the moment its process launches, before it finishes initializing and starts accepting queries. web can start and try to connect during that gap, which is a race condition, not a guaranteed failure, so it only crashes "occasionally."

Q2

After docker compose down and docker compose up -d again, your database still has all its data. After docker compose down -v, it doesn't. What's the difference?

docker compose down removes containers and the default network but deliberately leaves named volumes alone, so the new db container reattaches to the same volume and sees the same data. The -v flag explicitly opts into also removing named volumes, which deletes the underlying data — it's an intentional safety default that keeps a routine restart from silently wiping a development database.

Q3

Inside the web container, why does the hostname db resolve to the database container without any manual network configuration?

Compose automatically creates a network for the project and attaches every service in the file to it, giving each service DNS resolution by its service name — the exact mechanism you set up by hand with docker network create and --network flags in Week 3, just applied automatically to every service Compose manages instead of requiring one command per container.

Q4

Why should a database's data directory be a named volume in docker-compose.yml, but a local source-code mount for live-reload during development be a bind mount instead?

A named volume is managed entirely by Docker and portable across machines, which is what you want for data that must persist correctly regardless of the host's filesystem layout — exactly the property a database needs. A bind mount ties the container to a specific host path, which is undesirable for production data but is exactly the point for local development: it lets you edit source on the host and see the change reflected instantly inside the running container.