🐳 Lesson 5: Docker Compose
Stop juggling a dozen docker run flags. Describe your whole multi-container app in one declarative file and bring it up — services, network, and volumes — with a single command.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Write a modern
compose.yamlthat defines multiple services, networks, and volumes — with no obsoleteversion:key - Run and manage a full stack with
docker compose up,logs,ps, anddown - Explain how services discover each other by name on the default Compose network
- Use
docker compose watch, override files, and profiles to build a smooth local dev workflow
Estimated Time: 45–55 minutes
Prerequisite: Lessons 1–4 (containers, images, and Dockerfiles).
In This Lesson
The Problem Compose Solves
A real application is rarely a single container. A typical web app is a web frontend, an API, and a database — three containers that must be built, configured, networked together, and started in the right order. Run that by hand and you're typing something like this, over and over:
docker network create app-net
docker volume create db-data
docker run -d --name db --network app-net -v db-data:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret postgres:16-alpine
docker run -d --name api --network app-net -e DATABASE_URL=postgres://db:5432/app \
-p 8000:8000 myapp-api
docker run -d --name web --network app-net -p 8080:80 myapp-web
That's fragile, hard to share, and impossible to remember. Docker Compose replaces all of it with a single declarative file. You describe the desired state — which services exist, how they're built, what they connect to — and Compose makes it real with one command:
docker compose up -d
💡 It'sdocker compose, with a space. Compose is now a plugin built into the Docker CLI (Compose v2). The old standalonedocker-composebinary (v1, hyphenated) reached end-of-life and should no longer be used. Every command in this lesson uses the moderndocker composeform.
📖 Definition
Docker Compose: a tool for defining and running multi-container applications from a single YAML file. One file declares every service, network, and volume; one command brings the whole stack up or tears it down.
Anatomy of compose.yaml
The canonical filename today is compose.yaml. The older names docker-compose.yml and docker-compose.yaml still work and are auto-detected, but compose.yaml is what the Compose Specification recommends for new projects.
🚫 No more version:
Old tutorials start the file with version: "3.8". Don't. The top-level version key is obsolete and ignored by Compose v2 — it belongs to the legacy schema. A modern compose file starts directly with services:. If you still have it, Compose prints a warning telling you to remove it.
Here is a minimal, valid file — note it opens straight into services::
services:
web:
image: nginx:alpine
ports:
- "8080:80"
Each service supports a rich set of keys. The ones you'll reach for constantly:
| Key | What it does |
|---|---|
image | Pull a prebuilt image from a registry (e.g. postgres:16-alpine). |
build | Build from a local Dockerfile instead — a path, or a context/dockerfile pair. |
ports | Publish container ports to the host, "HOST:CONTAINER". |
environment | Set environment variables inside the container. |
env_file | Load variables from a file (e.g. .env) — keep secrets out of the compose file. |
volumes | Mount a named volume for persistence, or bind-mount host code for development. |
networks | Attach the service to one or more named networks. |
depends_on | Control start order — optionally waiting on a healthcheck. |
restart | Restart policy: no, always, on-failure, or unless-stopped. |
image vs build
Use image when someone else already published what you need (databases, caches, proxies). Use build when the service is your code:
services:
db:
image: postgres:16-alpine # pull a ready-made image
api:
build: # build your own from a Dockerfile
context: ./api
dockerfile: Dockerfile
depends_on and healthchecks
A plain depends_on only waits for a container to start, not for the program inside to be ready. A database process takes a moment to accept connections, so your API can still crash on boot. The robust fix is a healthcheck plus the condition: service_healthy form:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
api:
build: ./api
depends_on:
db:
condition: service_healthy # wait until db reports healthy
⚠️ Note: Even with healthchecks, well-written apps should still retry their first database connection. Startup ordering reduces flakiness; it isn't a substitute for resilient code.
A Complete Worked Stack
Let's assemble a realistic three-tier app: an Nginx web frontend, a Node API we build ourselves, and a Postgres database whose data lives in a named volume — all on a private network.
# compose.yaml — a web + api + database stack
services:
web:
image: nginx:alpine
ports:
- "8080:80"
depends_on:
- api
networks:
- frontend
api:
build: ./api
environment:
DATABASE_URL: postgres://appuser:secret@db:5432/appdb
env_file:
- .env
depends_on:
db:
condition: service_healthy
networks:
- frontend
- backend
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 3s
retries: 5
networks:
- backend
volumes:
db-data:
networks:
frontend:
backend:
Notice the shape: three services, a volumes: block declaring the named db-data volume, and a networks: block declaring two private networks. The db sits only on backend, so the web tier can't reach it directly — only the api bridges both.
Bring it up
docker compose up -d
Output:
[+] Running 5/5
✔ Network app_frontend Created
✔ Network app_backend Created
✔ Volume "app_db-data" Created
✔ Container app-db-1 Healthy
✔ Container app-api-1 Started
✔ Container app-web-1 Started
Check what's running, then watch the logs stream by:
docker compose ps
docker compose logs -f
Output of docker compose ps:
NAME IMAGE SERVICE STATUS PORTS
app-api-1 app-api api Up 20 seconds 0.0.0.0:8000->8000/tcp
app-db-1 postgres:16-alpine db Up 25 seconds (healthy)
app-web-1 nginx:alpine web Up 19 seconds 0.0.0.0:8080->80/tcp
Scale a service
Need more API workers? Scale horizontally without editing the file:
docker compose up -d --scale api=3
Compose starts three api containers sharing the same network; a reverse proxy or load balancer in front distributes requests across them. (Only stateless services should be scaled this way — never scale a single-writer database.)
Tear it down
docker compose down
This stops and removes the containers and networks — but keeps your named volume, so the database survives. To wipe the data too, add -v:
docker compose down -v # also removes the db-data volume
⚠️ Careful: docker compose down -v deletes named volumes declared in the file. That erases your database. Use it deliberately.
Service-to-Service Networking
This is the piece that surprises newcomers most. When Compose creates a network, it also runs an embedded DNS server. Every service is reachable from every other service on that network by its service name as the hostname. No IP addresses, no links, no manual config.
So in our stack, the API connects to Postgres at the host db — because the service is named db:
environment:
DATABASE_URL: postgres://appuser:secret@db:5432/appdb
# ^^ the service name
🧭 Host vs container ports
Services talk to each other on the container's internal port (Postgres on 5432) — you do not need a ports: mapping for that. ports: only publishes a service to the host (your laptop). A database usually needs no published port at all; keep it private on the backend network.
web calls http://api:8000, api reaches Postgres at db:5432, and db persists to the named db-data volume. Splitting frontend and backend networks keeps the database unreachable from the web tier.✅ The rule to remember
Inside a Compose network, reach another service at http://SERVICE_NAME:PORT — never localhost. From a container, localhost means that same container, not its neighbors.
Volumes & Persistence
Containers are ephemeral — remove one and everything written inside it is gone. That's fine for stateless apps, catastrophic for a database. Volumes are how Compose keeps data alive across restarts and rebuilds.
Named volumes — for data that must survive
A named volume is managed by Docker and outlives any single container. Declare it under the top-level volumes: key and mount it into the service:
services:
db:
image: postgres:16-alpine
volumes:
- db-data:/var/lib/postgresql/data # named volume → survives down/up
volumes:
db-data:
Bind mounts — for development
A bind mount maps a folder on your host straight into the container. Edit a file on your laptop and the container sees it instantly — perfect for iterating on source code:
services:
api:
build: ./api
volumes:
- ./api/src:/app/src # host path : container path
| Aspect | Named volume | Bind mount |
|---|---|---|
| Managed by | Docker | You (a host path) |
| Best for | Databases, persistent data | Live-editing source in dev |
| Syntax | name:/path | ./host:/path |
Survives down | Yes (unless -v) | N/A — it's your folder |
💡 Tip: Inspect what Docker is managing withdocker volume ls. Compose prefixes volume names with the project name, sodb-datashows up asapp_db-data.
The Modern Dev Workflow
🚀 docker compose watch — live sync and rebuild
The standout modern feature. Instead of bind-mounting and hoping your framework hot-reloads, docker compose watch lets you declare exactly what should happen when files change: sync files into the running container, or rebuild the image when dependencies change. Add a develop.watch block to the service:
services:
api:
build: ./api
develop:
watch:
- action: sync # copy changed source into the container
path: ./api/src
target: /app/src
- action: rebuild # rebuild when dependencies change
path: ./api/package.json
Then start watching:
docker compose watch
Output:
[+] Running 3/3
✔ Container app-db-1 Healthy
✔ Container app-api-1 Started
✔ Container app-web-1 Started
⦿ watching service "api" for changes...
Syncing "api" after changes: api/src/routes.js
Edit api/src/routes.js and Compose syncs it in place; change package.json and it rebuilds the image automatically. It's the cleanest inner-loop Compose has ever had.
Override files
Compose automatically merges a compose.override.yaml on top of your base compose.yaml. Keep production-safe defaults in the base file, and put developer conveniences (bind mounts, debug env vars, exposed ports) in the override:
# compose.override.yaml — merged automatically in dev
services:
api:
environment:
NODE_ENV: development
volumes:
- ./api/src:/app/src
To run without the override (e.g. a production-like run), name your files explicitly:
docker compose -f compose.yaml -f compose.prod.yaml up -d
Profiles
Profiles let one file describe optional services that only start when asked for. Tag a service with profiles: and it stays dormant until you enable its profile:
services:
api:
build: ./api
debug-tools:
image: nicolaka/netshoot
profiles: [debug] # only starts when the "debug" profile is active
docker compose --profile debug up -d
🔒 Scanning images for vulnerabilities
When you're ready to ship, scan your built images with docker scout (the modern replacement for the deprecated docker scan):
docker scout quickview
docker scout cves app-api
Try It Yourself
Time to build a real stack from scratch — a small web page served by Nginx plus a Postgres database — bring it up, prove it works, and tear it down.
🏋️ Exercise: A 2-service stack
Objective: Write a modern compose.yaml (no version: key), run it with docker compose, verify both services, then tear it down cleanly.
Instructions:
- Create a new folder and add a
compose.yamlwith two services: aweb(nginx:alpine, published on8080) and adb(postgres:16-alpine) with a named volume for its data. - Run
docker compose up -dand confirm both containers are up withdocker compose ps. - Open
http://localhost:8080— you should see the Nginx welcome page. - Follow the database logs with
docker compose logs -f dband watch it report ready to accept connections. - Tear everything down with
docker compose down. Then bring it back up and confirm the database data is still there (the named volume persisted).
💡 Hint
Postgres refuses to start without a password. Set POSTGRES_PASSWORD under the service's environment:. Remember: no top-level version: key — start the file with services:.
✅ Worked solution
A complete compose.yaml:
services:
web:
image: nginx:alpine
ports:
- "8080:80"
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Run and verify:
docker compose up -d
docker compose ps
docker compose logs -f db # Ctrl-C to stop following
docker compose down # containers gone, db-data volume kept
docker compose up -d # data is still there
Because the database wrote to the db-data named volume, its data survived the down/up cycle. Only docker compose down -v would have erased it.
Quick Quiz
Question 1: How does one service in a Compose stack connect to another (say, the API to the database)?
Question 2: What should a modern compose.yaml start with?
Question 3: You run docker compose down on a stack with a named volume for the database. What happens to the data?
Summary
🎉 Key Takeaways
- Compose turns many
docker runcommands into one declarative file — define services, networks, and volumes, then rundocker compose up -d. - Use
docker compose(a space), not the retireddocker-compose, and drop the obsoleteversion:key — modern files start atservices:. - Services reach each other by service name on the Compose network —
db:5432, neverlocalhost. - Named volumes persist data across
down/up;docker compose watchgives you a fast, modern dev inner loop.
📚 Additional Resources
- Docker Compose overview — the official docs
- Compose file reference — every key, current spec
- Use Compose Watch — the live-reload workflow in depth
- Using profiles with Compose — optional services on demand
🚀 What's Next?
You can now orchestrate a full multi-container stack locally. Next, we'll push into production territory — registries, deeper networking, image optimization, and the deployment strategies that take these ideas beyond your laptop.
🎉 Lesson 5 complete!
One file, one command, an entire stack. That's the power of Compose — see you in Advanced Docker!