🧩 Docker Compose for Multi-Container Applications
Real apps aren't one container — they're a web service, a database, a cache, and maybe a worker, all talking to each other. Docker Compose lets you describe that whole stack in one YAML file and bring it up or down with a single command.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what problem Docker Compose solves and read a
compose.yamlfile - Define services from images or a build context, with ports, env vars, and volumes
- Use Compose's automatic DNS network so services reach each other by name
- Persist data with named volumes and order startup with healthcheck-gated
depends_on - Run the core lifecycle commands:
up,down,logs,exec,build
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Compose a Node API + PostgreSQL stack that survives a restart.
In This Lesson
Why Docker Compose?
Running one container by hand is easy. Running four is a nightmare of long docker run commands: you have to create a network, start the database with the right env vars and volume, start the cache, then start the app pointing at both — in the right order, every time. Miss a flag and things silently break.
Docker Compose replaces all of that with a single declarative file. You describe the desired end state — which services exist, how they connect, what data persists — and Compose figures out how to make it real.
💡 A useful analogy: If a container is one musician, Compose is the conductor. You don't tell each player when to breathe; you hand them a score, raise the baton, and the whole ensemble starts together and stays in sync.
📖 A note on names
Modern Docker ships Compose as a built-in subcommand: docker compose up (a space, no hyphen). The old standalone docker-compose binary still works but is legacy. The file itself is now conventionally compose.yaml, though docker-compose.yml is still recognized.
Anatomy of a Compose File
A Compose file is YAML with a few top-level keys. Here is a complete, minimal example — a web server and a database — annotated:
services: # the containers that make up the app
web:
image: nginx:1.27-alpine
ports:
- "8080:80" # HOST:CONTAINER
volumes:
- ./site:/usr/share/nginx/html:ro
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
volumes:
- db_data:/var/lib/postgresql/data
volumes: # persistent, Docker-managed storage
db_data:
⚠️ The version: key is obsolete
Old tutorials start with version: '3'. The current Compose Specification ignores that field and will warn you about it. Just start with services: — no version line needed.
docker compose up brings the whole thing to life.Defining Services
Each entry under services: is a container. A service either pulls a prebuilt image: or builds from a Dockerfile with build:. The most common configuration keys:
services:
api:
build: # build from a Dockerfile...
context: ./api # ...in this directory
dockerfile: Dockerfile
# image: myorg/api:1.0 # ...or pull a prebuilt image instead
restart: unless-stopped # restart policy
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://app:secret@db:5432/app
env_file:
- .env # load extra vars from a file
volumes:
- ./api:/app # bind mount for live code
command: ["node", "server.js"] # override the image's CMD
💡 image vs build
Use image: for off-the-shelf services you don't modify (Postgres, Redis, Nginx). Use build: for your own code so Compose builds it as part of up. You rarely need both on one service.
Networking & Service Discovery
This is the feature that makes Compose feel magical. When you run docker compose up, Compose creates a private network and joins every service to it. Inside that network, each service is reachable by its service name as a hostname.
So the api service connects to Postgres at db:5432 — literally the string db, the service's name — with no IP addresses and no manual network wiring:
services:
api:
build: ./api
environment:
DATABASE_URL: postgres://app:secret@db:5432/app # "db" resolves via DNS
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
⚠️ ports is not how services talk to each other
ports: only publishes a port to your host so you can reach it from a browser. Service-to-service traffic on the internal network can use any port whether or not it's published — the db service does not need a ports entry for api to reach it.
Volumes & Environment
Containers are ephemeral — delete one and its filesystem is gone. To keep a database's data, you attach a volume. Compose supports two kinds you'll use constantly:
| Type | Syntax | Use for |
|---|---|---|
| Named volume | db_data:/var/lib/... | Persistent data Docker manages (databases) |
| Bind mount | ./src:/app/src | Live-editing your source during development |
Named volumes must also be declared in the top-level volumes: block so Docker creates and tracks them:
services:
db:
image: postgres:16
volumes:
- db_data:/var/lib/postgresql/data # named volume: survives `down`
web:
build: ./web
volumes:
- ./web/src:/app/src # bind mount: host edits show instantly
volumes:
db_data:
Keeping secrets out of the file with variable substitution
Compose reads a .env file sitting next to the Compose file and substitutes ${VAR} references. This lets you commit the Compose file to Git while keeping passwords out of it:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD} # pulled from .env
POSTGRES_DB: ${DB_NAME:-app} # default if unset
# .env (git-ignored)
DB_PASSWORD=super-secret
DB_NAME=myapp
Startup Order & Healthchecks
depends_on controls the order services start — but by default it only waits for the dependency's container to start, not for the service inside it to be ready. Postgres takes a couple of seconds to accept connections, so a plain depends_on: [db] can still let your API start too early and crash.
The fix is a healthcheck on the dependency plus a condition: service_healthy on the dependant:
services:
api:
build: ./api
depends_on:
db:
condition: service_healthy # wait until db is actually ready
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
✅ Ready, not just started
With a healthcheck gate, Compose holds the api container until pg_isready succeeds. This is the reliable way to solve the classic "database isn't up yet" race on startup.
Everyday Commands
You'll live in a handful of subcommands. Note the modern space-separated form (docker compose):
# Build (if needed) and start everything in the background
docker compose up -d
# Rebuild images then start
docker compose up -d --build
# See what's running
docker compose ps
# Follow logs for all services, or one
docker compose logs -f
docker compose logs -f api
# Run a one-off command inside a running service
docker compose exec api sh
docker compose exec db psql -U app
# Stop and remove containers + network (named volumes are KEPT)
docker compose down
# Also remove the named volumes (deletes your data!)
docker compose down -v
⚠️ down -v deletes your data
Plain docker compose down keeps named volumes, so your database survives. Adding -v wipes them. Reach for -v only when you deliberately want a clean slate.
Hands-on Exercise
🏋️ Compose a Node API with a persistent PostgreSQL
Objective: Stand up a two-service stack where the API reaches the database by name and the data survives a full down/up cycle.
Instructions:
- In a project folder, put your API (with its Dockerfile) in
./api. - Write a
compose.yamlwith anapiservice (built from./api) and adbservice usingpostgres:16. - Give
dba named volume and apg_isreadyhealthcheck; gateapioncondition: service_healthy. - Point the API's
DATABASE_URLat hostnamedb. - Run
docker compose up -d, write a row, thendocker compose downandupagain — confirm the row is still there.
💡 Hint
If the data vanishes after down/up, check two things: the volume is declared in the top-level volumes: block, and you did not pass -v to down. If the API crashes on boot, your healthcheck gate is probably missing.
✅ Solution
services:
api:
build: ./api
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
volumes:
db_data:
The named db_data volume is kept by down, so restarting preserves the row. The healthcheck gate guarantees the API never starts before Postgres accepts connections.
🎯 Quick Quiz
Question 1: Inside a Compose network, how does the api service reach the db service?
Question 2: What does plain docker compose down do to your named volumes?
Question 3: Why add a healthcheck and condition: service_healthy instead of a plain depends_on?
Summary & Quiz
🎉 Key Takeaways
- Docker Compose declares a whole multi-container stack in one file and manages it with one command.
- Start the file with
services:— the oldversion:key is obsolete. - Compose's private network gives every service DNS by name, so
db:5432just works. - Named volumes persist data across
down/up; bind mounts live-edit source in development. - Gate startup with a healthcheck +
service_healthyto avoid "database isn't ready" races.
📚 Further Reading
🚀 What's Next?
Compose isn't just for running apps — VS Code can build your entire coding environment from a Compose file too. Next we'll turn these ideas into a development container that boots a ready-to-code workspace.
🎉 Nice work!
You can now orchestrate a full stack of services from a single file. Let's make your dev environment just as reproducible.