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.
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:
# 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.
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:
# 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.
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.
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.
# ── 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
| Key | What it does |
|---|---|
build | Path to a Dockerfile (or an object with context: and dockerfile:). Compose builds the image automatically. |
image | Pull this image from a registry instead of building. Can also name the built image when used with build. |
ports | Publish host:container ports. Only the api needs to be reachable from outside; internal services should omit this. |
environment | Inline environment variables. Supports ${VAR} substitution from a .env file. |
volumes | Mount a named volume or bind mount path into the container. |
depends_on | Controls start order. With condition: service_healthy, waits for a passing healthcheck (see Section 5). |
restart | no, always, on-failure, or unless-stopped. Governs what happens if the container exits. |
networks | Which Compose-managed network(s) this service joins. Services on the same network can reach each other by service name. |
healthcheck | A command Compose runs inside the container to determine if the service is ready. Enables service_healthy condition. |
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.
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.
# 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
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.
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.
# 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
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.
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:
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
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.
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.
# 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:
# never commit this file to git
DB_PASS=supersecretpassword
POSTGRES_DB=app
DEBUG=false
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.
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.
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.
| Command | What it does |
|---|---|
docker compose up | Build (if needed), create, and start all services. Attaches to logs in the foreground. |
docker compose up -d | Same but detached, returns the prompt immediately. You'll use this most. |
docker compose up --build | Force-rebuild all service images before starting. Use whenever you change code. |
docker compose down | Stop and remove containers and the default network. Volumes are preserved. |
docker compose down -v | Down + delete named volumes. Data is gone. Use with intent. |
docker compose ps | List running services and their status/ports. |
docker compose logs | Print logs from all services. |
docker compose logs -f | Follow logs in real time (like tail -f). Add a service name to filter: logs -f api. |
docker compose exec api sh | Open a shell inside a running service container. Like docker exec -it. |
docker compose build | Build all service images without starting them. |
docker compose restart api | Restart a specific service without touching others. |
docker compose up --scale api=3 | Run 3 instances of the api service. Stateless services only, so don't scale services with host port bindings. |
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.
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
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
# 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
# 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
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.
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.yamlwith three services: a web app (built from a Dockerfile), a Postgres database, and a Redis cache. Reference the anatomy in Section 2. - Write a
.envfile withDB_PASSand add it to.gitignore. Create a.env.examplewith placeholder values and commit that instead. - Run
docker compose up -d --build. Confirm all three services are running withdocker compose ps. - From inside the
apicontainer (docker compose exec api sh), try to connect to the database using the hostnamedb. Confirm that service-name DNS works. - Add a
healthcheckto thedbservice and change theapi'sdepends_ontocondition: 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 withdocker compose ps. Note: this only works if api has no static host port binding, so changeportsto"8000"(container port only) first. - Run
docker compose logs -fand watch log output from all services simultaneously. Then filter to one:docker compose logs -f db. - Run
docker compose downand confirm withdocker volume lsthat the named volume still exists. - Run
docker compose down -vand 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 runcp .env.example .env && docker compose up -dsuccessfully.
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.