π³ Docker Compose Basics
Running one container by hand is fine for a demo. Running a web server, an API, and a database β each with its own image, ports, and environment β turns into a wall of docker run flags you have to remember perfectly every time. Docker Compose replaces all of that with a single file you commit to git and a single command: docker compose up.
Week 11 · Day 3 (Wednesday: Docker Compose) · Lecture 1
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what Docker Compose solves and when to reach for it
- Write a modern
compose.yamlusing thedocker composev2 CLI (no legacyversion:key) - Configure a service with
image/build,ports,environment,env_file, anddepends_on - Persist database data with a named volume and reach services by name over the default network
- Drive the stack with
up -d,down,logs,ps, andexec - Apply
.envvariable substitution and a healthcheck-gateddepends_on
Estimated Time: 60 minutes
Practice: Author a three-service compose.yaml (web + API + Postgres) and run it end to end.
In This Lesson
Why Docker Compose?
Docker Compose is a tool for defining and running multi-container applications. You describe your whole system β services, networks, volumes β in one YAML file, then bring it all up (or tear it all down) with a single command. The file becomes the single source of truth that anyone on your team can check out and run identically.
π» The orchestra analogy
A single container is a musician who plays one instrument well. Your compose.yaml is the score every musician reads from. The docker compose command is the conductor β deciding when each part starts and keeping them in time. The running application is the symphony: something no single container could produce alone.
Before Compose, spinning up a web app with a database meant a growing list of imperative commands β create a network, run the database with the right flags, run the API pointing at it, run the web server pointing at that. Miss a flag and nothing connects. Compose turns that fragile ritual into declarative, version-controlled configuration.
When to reach for it
| Great fit | Why |
|---|---|
| Local development | One command spins up your entire stack, identically for every teammate |
| Automated testing / CI | Bring services up, run integration tests, tear everything down |
| Single-host deployments | Small production apps on one server (a VPS or homelab box) |
| Sharing reproducible demos | A tutorial anyone can run with docker compose up |
π‘ Compose vs. orchestrators
Compose is designed for a single host. When you need to spread containers across many machines with self-healing and rolling updates, that's the job of an orchestrator like Kubernetes β a later topic. Compose remains the fastest path from zero to a running stack on your laptop.
The compose.yaml File
The heart of Compose is a YAML file that declares your services. Modern Compose (the docker compose v2 plugin that ships with Docker Desktop and recent Docker Engine) reads a file named compose.yaml by default. The older docker-compose.yml name still works, but new projects should prefer compose.yaml.
β οΈ Two things that changed in modern Compose
- The command is
docker compose(a space), notdocker-compose(a hyphen). The hyphenated v1 tool is deprecated; v2 is a subcommand of the Docker CLI written in Go. - The top-level
version:key is obsolete. You'll seeversion: '3.8'in older tutorials β modern Compose ignores it and will warn you. Just delete it and start withservices:.
Minimal structure
services:
web:
# configuration for the web service
api:
# configuration for the api service
volumes:
# named volumes declared here
networks:
# custom networks declared here (optional β a default one is created for you)
Only services: is required. volumes: and networks: are declared at the top level when you want named, reusable ones β otherwise Compose creates a default network automatically and connects every service to it.
Anatomy of a Service
Each entry under services: becomes one container. The key you choose (like web or db) is both the container's name and its DNS hostname on the network β that's how services find each other. Here are the options you'll use constantly:
| Key | What it does |
|---|---|
image | Use a prebuilt image from a registry, e.g. postgres:16 |
build | Build an image from a local Dockerfile instead of pulling one |
ports | Publish a container port to the host β "HOST:CONTAINER" |
environment | Set environment variables inside the container |
env_file | Load environment variables from a file (keeps secrets out of the YAML) |
volumes | Mount a named volume (persistence) or a host path (bind mount for dev) |
depends_on | Control start order, optionally waiting for a healthcheck to pass |
restart | Restart policy, e.g. unless-stopped for long-running services |
A Real web + api + db Stack
Let's put it all together. This is the classic three-tier layout: an Nginx web front end, a Node.js API built from a local Dockerfile, and a Postgres database whose data survives restarts thanks to a named volume. Study it closely β it's the template you'll adapt for nearly every project.
# compose.yaml β no "version:" key needed in modern Compose
services:
web:
image: nginx:alpine
ports:
- "8080:80" # visit http://localhost:8080
volumes:
- ./site:/usr/share/nginx/html:ro # bind mount static files, read-only
depends_on:
- api
api:
build: ./api # builds ./api/Dockerfile
ports:
- "3000:3000"
environment:
NODE_ENV: development
# "db" is the service name β Compose resolves it to the db container's IP
DATABASE_URL: postgres://app:secret@db:5432/appdb
depends_on:
db:
condition: service_healthy # wait until the DB passes its healthcheck
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- db_data:/var/lib/postgresql/data # named volume β data persists
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 5
volumes:
db_data: # declared here so Docker manages it
Three ideas in this file do all the heavy lifting, and they're worth calling out because they're what beginners miss:
- Service-name DNS. The API's
DATABASE_URLpoints at the hostdb, notlocalhost. Compose runs an internal DNS server so every service can reach every other by its service name. There is no IP address to hard-code. - A named volume for the database.
db_datalives outside the container's writable layer, sodocker compose downand a freshupkeep your rows intact. Bind-mounting the web server's static files (with:ro) is the right call there because those are just files on disk you want to edit live. - Healthcheck-gated startup. Plain
depends_ononly waits for the container to start, not for Postgres to be ready to accept connections. Addingcondition: service_healthyplus ahealthcheckmakes the API wait untilpg_isreadysucceeds β eliminating the classic "connection refused" race on the first boot.
β Why this beats a pile of docker run commands
Everything above β build steps, ports, the database password, the startup order, the persistence β lives in one reviewable file. A teammate clones the repo and types docker compose up. No README full of "first run this, then that." That reproducibility is the whole point.
Essential Commands
Every command runs from the directory containing your compose.yaml. Here is the daily-driver set β memorize these five and you can run any Compose project.
# Build (if needed) and start everything in the foreground; Ctrl-C to stop
docker compose up
# Start in the background (detached) β the usual choice
docker compose up -d
# Rebuild images before starting (after changing a Dockerfile)
docker compose up -d --build
# See what's running, with status and published ports
docker compose ps
# Follow the combined logs; add a service name to narrow it down
docker compose logs -f
docker compose logs -f api
# Open a shell inside a running service
docker compose exec api sh
docker compose exec db psql -U app -d appdb
# Stop and remove containers + the default network (volumes are KEPT)
docker compose down
# β¦and also delete named volumes (DESTROYS your database data)
docker compose down -v
β οΈ down vs. down -v
Plain docker compose down keeps your named volumes, so your database data is safe. Adding -v deletes them too. Reach for -v only when you deliberately want a clean slate β it is the single fastest way to accidentally wipe local data.
The container lifecycle at a glance
Output of docker compose ps
NAME IMAGE SERVICE STATUS PORTS
app-web-1 nginx:alpine web Up 2 minutes 0.0.0.0:8080->80/tcp
app-api-1 app-api api Up 2 minutes 0.0.0.0:3000->3000/tcp
app-db-1 postgres:16 db Up 2 minutes 5432/tcp
Environment & .env Files
Hard-coding ports and passwords into compose.yaml is fine for a throwaway demo and a bad habit everywhere else. Compose gives you two complementary tools: a project-level .env file for values it substitutes into the YAML, and the per-service env_file for variables passed into a container.
1. A .env file for substitution
Compose automatically reads a file named .env sitting next to compose.yaml and substitutes ${VARIABLE} references in the YAML:
# .env (commit a .env.example, never the real secrets)
POSTGRES_PASSWORD=secret
API_PORT=3000
PG_VERSION=16
services:
api:
build: ./api
ports:
- "${API_PORT}:3000"
db:
image: postgres:${PG_VERSION}
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# Provide a fallback if the variable is unset:
LOG_LEVEL: ${LOG_LEVEL:-info}
2. env_file for values inside a container
When a service needs a whole batch of variables, point it at a file instead of listing each one:
services:
api:
build: ./api
env_file:
- ./api/.env.local
π‘ Two different jobs
The top-level .env file feeds Compose's own variable substitution (the ${...} in your YAML). A service's env_file feeds variables straight into that container's environment. They're often both used, and it's worth keeping the distinction clear. Always add .env to .gitignore and commit a redacted .env.example so teammates know which keys to set.
Practice & Quiz
ποΈ Exercise 1: Your first two-service stack
Goal: Write a compose.yaml that runs Nginx serving a static folder plus a Postgres database with persistent storage. Nginx should be reachable at http://localhost:8080.
# compose.yaml β fill in the blanks
services:
web:
image: nginx:alpine
# TODO: publish container port 80 on host 8080
# TODO: bind mount ./site read-only into /usr/share/nginx/html
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
# TODO: persist /var/lib/postgresql/data with a named volume
# TODO: declare the named volume
π‘ Hint
Ports go under a ports: list as "8080:80". A read-only bind mount ends in :ro. A named volume needs both a mount line in the service (db_data:/var/lib/postgresql/data) and a top-level volumes: entry naming db_data.
β Solution
services:
web:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./site:/usr/share/nginx/html:ro
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
Run it with docker compose up -d, confirm with docker compose ps, and tear it down with docker compose down (your data stays because you did not pass -v).
ποΈ Exercise 2: Wire the API to the database
Goal: Add an api service (built from ./api) that connects to the db service by name and only starts once the database is healthy.
β Solution
api:
build: ./api
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/postgres
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- db_data:/var/lib/postgresql/data
The key detail: the host in DATABASE_URL is db β the service name β not an IP or localhost.
π― Quick Quiz
Question 1: In modern Docker, which command starts a Compose project?
Question 2: How does the api service reach the db service?
Question 3: Which command removes containers but keeps your database's named volume?
Best Practices & Pitfalls
β Do
- Name the file
compose.yamland use thedocker compose(v2) CLI - Commit
compose.yamlto git alongside your application code - Use named volumes for databases so data survives
downandup - Keep secrets in a
.gitignored.env; commit a redacted.env.example - Gate
depends_onwithcondition: service_healthyfor databases - Give services clear names β they double as DNS hostnames
β Don't
- Add a top-level
version:key β it's obsolete and triggers a warning - Use the hyphenated
docker-composefor new work β it's the deprecated v1 - Reach a service via
localhostfrom another container β use the service name - Run
down -vout of habit β it wipes your named volumes - Assume plain
depends_onwaits for a database to be ready β it only waits for the container to start
β οΈ The "connection refused on first run" trap
The most common Compose bug: the API boots faster than Postgres finishes initializing and crashes with ECONNREFUSED. The fix is the healthcheck-gated depends_on shown earlier, plus retry-with-backoff logic in your app's database client. Never rely on start order alone.
Summary
π Key Takeaways
- Compose declares a whole stack in one
compose.yamland runs it withdocker compose up - Modern Compose uses the space command
docker composeand noversion:key - Services find each other by service name over the automatic default network
- Named volumes persist database data; bind mounts share host files for dev
- Master
up -d,down,logs,ps,execβ and knowdown -vdeletes data - Gate
depends_onwith a healthcheck to avoid startup races
π Additional Resources
- Docker Docs β Docker Compose overview
- Docker Docs β Compose file reference
- Docker Docs β Compose quickstart
- Awesome Compose β real example projects
π What's Next?
You can now define and run a stack. Next we scale that skill up: designing genuinely multi-container applications β service-to-service communication, sensible architecture patterns, and a real e-commerce example that puts several services and databases to work together.
π Well done!
You've replaced a wall of docker run flags with one clean, version-controlled file. That's the foundation everything else this week builds on.