← Learn
Docker Course  /  Phase 1  /  Module 05
Day 5 ~2.5h · project-ready
Phase 1 · Docker · Day 5

Module 05: Docker Compose
Multi-Container Apps

You can build images, persist data, and connect containers. Now comes the module where those pieces click together. Sam, a developer shipping their first containerized app, has all the parts working in isolation but no clean way to start them together. Compose turns a stack of manual docker run commands into a single declarative file that anyone can run. It is the foundation of every containerized project you will ever ship.

~2.5h · project-ready builds on Modules 02, 03 & 04 Compose · YAML · multi-service Docker 29.5 · CachyOS
0

Where we left off

You have covered a lot of ground. Module 02 taught you how to build images with Dockerfiles and layer caching. Module 03 showed you how to persist data outside containers using volumes and bind mounts. Module 04 gave you the networking model: user-defined bridge networks, published ports, and (crucially) container-to-container DNS by service name.

Sam has exactly these three pieces ready: a web API, a Postgres database, and a Redis cache. The goal is to ship them as one app. The problem shows up the moment Sam tries to run it. To start this stack today you would need to:

what you'd have to do without Compose
# 1. create the network manually
docker network create myapp-net

# 2. create the named volume manually
docker volume create pgdata

# 3. start postgres: remember every flag every time
docker run -d --name db --network myapp-net \
  -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16

# 4. start redis
docker run -d --name cache --network myapp-net redis:7-alpine

# 5. build and start the API: hope you remembered the build context
docker build -t myapi:dev .
docker run -d --name api --network myapp-net \
  -p 8000:8000 \
  -e DATABASE_URL=postgresql://postgres:secret@db:5432/app \
  -e REDIS_URL=redis://cache:6379 \
  myapi:dev

Every time Sam tears down and restarts, all of this gets repeated. Every teammate has to know every flag. There's no single source of truth for how the app is configured. This is the problem Compose solves.

Modules 02 to 04 done

Images, volumes, networking. You know the primitives. Compose is just a declarative wrapper over them.

The pain point

Multi-container setup by hand is tedious, error-prone, and not repeatable. One missed flag and something doesn't connect, which is precisely where Sam got stuck.

The fix

A single compose.yaml replaces all those commands. docker compose up does it all, every time, identically.

1

What Compose is

Docker Compose is a tool for defining and running multi-container applications from a single declarative YAML file, conventionally named compose.yaml (older projects use docker-compose.yml; the new canonical name is compose.yaml).

You describe your entire application in one file: every service, its image or build context, its ports, its environment variables, its volumes, and how services depend on each other. Then one command brings the whole thing up. This is the file Sam will write once and rerun forever:

terminal
# start everything: builds images if needed, creates network/volumes, starts services
docker compose up -d

# tear it all down
docker compose down

Under the hood, Compose does exactly what you would do manually: it creates a user-defined bridge network, creates named volumes, builds or pulls images, and runs containers. The difference is that it reads all the configuration from your YAML file instead of requiring you to type it out every time.

Why Compose is the natural next step

Compose is declarative (you describe the desired end state, not the steps to reach it), version-controllable (it's a text file, so commit it to git), and reproducible (same YAML produces the same running environment on any machine with Docker installed). It is the standard way to ship a multi-service app for development and small-to-medium production deployments. Every Docker job you encounter will expect you to be comfortable with it.

2

The compose file anatomy

A compose.yaml has three top-level sections: services, volumes, and networks. The most important is services, where each entry under it becomes a container. Here is a fully annotated real-world example, the exact shape of Sam's stack: a web API, a Postgres database, and a Redis cache.

compose.yaml: annotated 3-service app
# ── top-level services block ──────────────────────────────────────────
services:

  # ── SERVICE 1: web API (built from a local Dockerfile) ────────────────
  api:
    build: .                          # build from Dockerfile in current dir
    ports:
      - "8000:8000"                  # host:container, publish to the host
    environment:
      DATABASE_URL: postgresql://postgres:${DB_PASS}@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy  # wait until db passes its healthcheck
      cache:
        condition: service_started  # redis starts fast, just wait for start
    restart: unless-stopped          # restart if it crashes, stop on explicit down
    networks:
      - appnet

  # ── SERVICE 2: Postgres database (pulled from Docker Hub) ─────────────
  db:
    image: postgres:16-alpine        # use an official image, no Dockerfile needed
    environment:
      POSTGRES_PASSWORD: ${DB_PASS}  # variable from .env file or shell
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data # named volume, data survives compose down
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    networks:
      - appnet

  # ── SERVICE 3: Redis cache (pulled from Docker Hub) ────────────────────
  cache:
    image: redis:7-alpine
    restart: unless-stopped
    networks:
      - appnet

# ── named volumes block ────────────────────────────────────────────────
volumes:
  pgdata:                            # Compose creates this volume if it doesn't exist

# ── networks block ─────────────────────────────────────────────────────
networks:
  appnet:                            # Compose creates a user-defined bridge network

The per-service keys explained

KeyWhat it does
buildPath to a Dockerfile (or an object with context: and dockerfile:). Compose builds the image automatically.
imagePull this image from a registry instead of building. Can also name the built image when used with build.
portsPublish host:container ports. Only the api needs to be reachable from outside; internal services should omit this.
environmentInline environment variables. Supports ${VAR} substitution from a .env file.
volumesMount a named volume or bind mount path into the container.
depends_onControls start order. With condition: service_healthy, waits for a passing healthcheck (see Section 5).
restartno, always, on-failure, or unless-stopped. Governs what happens if the container exits.
networksWhich Compose-managed network(s) this service joins. Services on the same network can reach each other by service name.
healthcheckA command Compose runs inside the container to determine if the service is ready. Enables service_healthy condition.
Default network behaviour

If you omit the networks block entirely, Compose still creates a single default bridge network and attaches all services to it automatically. Naming it explicitly (as above) is clearer and lets you add additional networks later without restructuring the file.

3

build vs image

Every service in a Compose file needs an image to run from. You tell Compose where to get it in one of two ways, and understanding the distinction is important to get right.

build: build locally

Points to a directory containing a Dockerfile. Compose runs docker build and uses the resulting image. Use this for your own services, code you own and actively change. For Sam, that's the API.

image: pull from registry

Pulls an existing image from Docker Hub or any registry. Use this for third-party services such as databases, caches, and message queues, where you're not writing the image yourself.

build forms
# short form: Dockerfile lives in the same directory as compose.yaml
build: .

# long form: specify a different context and/or Dockerfile name
build:
  context: ./services/api
  dockerfile: Dockerfile.prod

# you can also name the built image (useful for pushing it later)
build: .
image: myorg/myapi:latest
docker compose up vs docker compose up --build

By default, docker compose up builds the image only if it does not already exist locally. If you change your source code and run up again, it will use the old cached image. To force a rebuild, always run docker compose up --build during active development. This is one of the most common gotchas, the one that cost Sam an afternoon: "why isn't my change showing up?" The answer is that the image was not rebuilt.

4

Networking & service discovery

This is where Module 04 pays off. When Compose creates a user-defined bridge network (which it always does, either explicitly or automatically), Docker's embedded DNS resolver is active on that network. Every container gets a DNS entry equal to its service name in the compose file.

That means, inside the api container, you can connect to Postgres using the hostname db, not an IP address, not a port number guessing game. This is identical to what you learned in Module 04 when you put containers on the same user-defined bridge and reached them by name. Compose just automates that setup.

how service discovery works
# in your application code (Python example, sketch, not full code)
# the hostname is the Compose SERVICE NAME, not "localhost", not an IP
DATABASE_URL = "postgresql://postgres:secret@db:5432/app"
#                                              ^^
#                                              service name from compose.yaml

REDIS_URL = "redis://cache:6379"
#                    ^^^^^
#                    service name: Compose DNS resolves it to the container's IP
The tie-back to Module 04

In Module 04 you manually created a user-defined network and used --name to give each container a DNS-resolvable hostname. Compose does exactly that, automatically, for every service. The service name is the DNS hostname. This is not new magic; it is the same mechanism, just automated. When someone asks "how do containers in Compose talk to each other," the answer is: user-defined bridge network with automatic DNS on service names.

Only expose what needs exposure

Notice in the anatomy example that only the api service has a ports mapping. The db and cache services do not publish ports to the host. They are only reachable from inside the Compose network. This is the correct posture: your database should not be exposed on localhost:5432 in production. Services talk to each other via the Compose network; only the entry point (the API) is published outward.

5

depends_on & healthchecks

This section addresses one of the most common real-world bugs with Compose. People add depends_on and then wonder why their app crashes on startup even though it lists the database as a dependency.

The reason is that depends_on alone only controls start order. It tells Compose "start the db container before the api container." It does not wait for the database to actually be ready to accept connections. Postgres takes a few seconds to initialise its data directory after the container starts. Sam's API tried to connect immediately on first boot, and it failed.

depends_on alone: NOT readiness

Container started does not mean service ready. Postgres may be initialising. Your app will crash, then restart, until Postgres accepts connections.

depends_on + healthcheck: actual readiness

Compose waits until the healthcheck passes before starting dependent services. This is the correct pattern for any service with a startup delay.

Here is the full pattern. You define a healthcheck on the db service, and then reference it in the api's depends_on with condition: service_healthy:

healthcheck pattern
services:

  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      # pg_isready exits 0 when Postgres accepts connections, non-zero otherwise
      interval: 5s    # run the check every 5 seconds
      timeout: 5s     # fail if the check takes more than 5s
      retries: 5      # mark unhealthy after 5 consecutive failures
      start_period: 10s # grace period on first start (don't count failures yet)

  api:
    build: .
    depends_on:
      db:
        condition: service_healthy
        # Compose holds api until db's healthcheck passes
The three condition values

service_started means the container has started (the default when you just write depends_on: [db]). service_healthy means the container has a passing healthcheck. service_completed_successfully is for init containers or migration jobs that run once and exit 0. Use service_healthy for databases. Use service_started only for services that are genuinely ready immediately.

6

environment & env files

There are three ways to pass environment variables into a Compose service, and knowing when to use each is a real-world skill that signals real maturity.

three ways to pass environment variables
# 1. inline: simple, but hardcoded values end up committed to git
environment:
  NODE_ENV: production
  PORT: "8000"

# 2. variable substitution: values come from a .env file or from the shell
environment:
  DATABASE_URL: postgresql://postgres:${DB_PASS}@db:5432/app
  # Compose reads .env in the same directory as compose.yaml automatically

# 3. env_file: point to a file; each line is KEY=VALUE
env_file:
  - .env               # loaded automatically anyway, but can list other files
  - .env.local         # useful for per-developer overrides

The .env file

Compose automatically loads a file named .env in the same directory as compose.yaml. Variables defined there are available for ${VAR} substitution anywhere in the compose file. The format is one KEY=VALUE per line, with # for comments:

.env
# never commit this file to git
DB_PASS=supersecretpassword
POSTGRES_DB=app
DEBUG=false
Keep secrets out of version control

Add .env to your .gitignore. Commit a .env.example instead, a copy with placeholder values that tells teammates what variables are needed without exposing actual credentials. Hardcoding passwords directly in compose.yaml is a serious security mistake because the compose file typically is committed to git.

Variable precedence

Shell environment variables override .env file values. So a developer can run DB_PASS=localpass docker compose up to temporarily override a value without editing the file. This is useful for CI pipelines where secrets are injected via shell environment.

7

The Compose CLI

The Compose CLI is built into Docker (as docker compose, note the space, not a hyphen). You will use a small set of commands constantly. Know these cold.

CommandWhat it does
docker compose upBuild (if needed), create, and start all services. Attaches to logs in the foreground.
docker compose up -dSame but detached, returns the prompt immediately. You'll use this most.
docker compose up --buildForce-rebuild all service images before starting. Use whenever you change code.
docker compose downStop and remove containers and the default network. Volumes are preserved.
docker compose down -vDown + delete named volumes. Data is gone. Use with intent.
docker compose psList running services and their status/ports.
docker compose logsPrint logs from all services.
docker compose logs -fFollow logs in real time (like tail -f). Add a service name to filter: logs -f api.
docker compose exec api shOpen a shell inside a running service container. Like docker exec -it.
docker compose buildBuild all service images without starting them.
docker compose restart apiRestart a specific service without touching others.
docker compose up --scale api=3Run 3 instances of the api service. Stateless services only, so don't scale services with host port bindings.
down vs down -v: know the difference

docker compose down removes containers and networks but leaves named volumes intact. Your Postgres data survives. docker compose down -v also removes volumes, so the database is wiped. This is intentional in some situations (resetting a dev environment to a clean state) but catastrophic if you run it by accident against a volume with real data. Be deliberate.

8

The full project walkthrough

Let's pull everything together and walk through Sam's finished project end to end, from directory layout to running and tearing down. This is the pattern you will use in every containerized project you build.

Directory layout

project structure
myapp/
├── compose.yaml         # the whole app in one file
├── .env                 # secrets, gitignored
├── .env.example         # committed placeholder
├── .gitignore           # includes .env
├── Dockerfile           # for the api service
└── src/
    └── main.py          # application code (sketch)

Bring the app up

terminal: full workflow
# from the project root (where compose.yaml lives)
docker compose up -d --build

# what happens:
[+] Building 12.3s (7/7) FINISHED
[+] Running 4/4
 ✔ Network myapp_appnet   Created
 ✔ Volume  myapp_pgdata   Created
 ✔ Container myapp-db-1   Healthy
 ✔ Container myapp-api-1  Started

# confirm everything is running
docker compose ps

# stream logs from all services (Ctrl+C to stop)
docker compose logs -f

# stream logs from one service only
docker compose logs -f api

# open a shell inside the api container to debug
docker compose exec api sh

Tear down

terminal: teardown options
# stop containers + remove network, volumes survive
docker compose down

# stop + remove everything INCLUDING volumes (data is gone)
docker compose down -v

# just stop (don't remove containers)
docker compose stop
This is a portfolio-ready containerized app

A git repository with a compose.yaml, a Dockerfile, a .env.example, and source code is a complete, reproducible, multi-service application that any developer can clone and run with two commands: cp .env.example .env && docker compose up -d. That is what "containerized" means in practice. This is what you put on your portfolio. Go build it before moving on to Module 06.

9

Hands-on: do this now

This module only sticks if you run it yourself. The checklist below takes about 45 minutes if you go through it properly. Do not skip it.

  • Create a project directory and write a compose.yaml with three services: a web app (built from a Dockerfile), a Postgres database, and a Redis cache. Reference the anatomy in Section 2.
  • Write a .env file with DB_PASS and add it to .gitignore. Create a .env.example with placeholder values and commit that instead.
  • Run docker compose up -d --build. Confirm all three services are running with docker compose ps.
  • From inside the api container (docker compose exec api sh), try to connect to the database using the hostname db. Confirm that service-name DNS works.
  • Add a healthcheck to the db service and change the api's depends_on to condition: service_healthy. Bring the stack down and up again. Watch Compose wait for the healthcheck to pass before starting the API.
  • Scale the api service to 3 replicas with docker compose up -d --scale api=3. Check with docker compose ps. Note: this only works if api has no static host port binding, so change ports to "8000" (container port only) first.
  • Run docker compose logs -f and watch log output from all services simultaneously. Then filter to one: docker compose logs -f db.
  • Run docker compose down and confirm with docker volume ls that the named volume still exists.
  • Run docker compose down -v and confirm the volume is gone. Understand what you just destroyed and why that distinction matters.
  • Commit the whole project to a git repository (without .env). Verify a teammate could clone it and run cp .env.example .env && docker compose up -d successfully.
The goal of the hands-on

By the time you tick the last box, you have a real multi-service containerized project in a git repo. That is not a tutorial exercise. That is a portfolio item, and it is exactly where Sam landed: one file, two commands, a stack that comes up the same way every time. The muscle memory of writing a Compose file from scratch, making services discover each other, and confidently tearing things down is what "I know Docker Compose" actually means.