1. Anatomy of a docker-compose.yml
A Compose file declares a set of services — each roughly
corresponding to one docker run command you wrote by hand last week —
plus the networks and volumes they share. Docker Compose reads it and creates,
starts, connects, and tears down every piece as a single unit.
services:
web:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:app@db:5432/appdb
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
networks:
- app-net
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
networks:
- app-net
cache:
image: redis:7-alpine
networks:
- app-net
networks:
app-net:
volumes:
pgdata:
Every top-level key maps directly to something you already know from Week 3:
build is the Week 3 Dockerfile, ports is
-p host:container, environment is -e, and the
top-level networks:/volumes: blocks declare resources that
Compose creates for you instead of you running docker network create
and docker volume create by hand.
The plaintext password above is fine for a local Postgres container that never leaves your machine, but the same pattern in a file committed to Git is a real leak. Use an .env file (add it to .gitignore) or Compose's env_file: key for anything resembling a real credential, even in development — habits you build here carry straight into the CI secrets you'll configure in Week 6.
2. Running a Multi-Service App
With the file above saved as docker-compose.yml, the entire stack —
app, database, and cache — comes up with one command:
docker compose up # build/pull images and start every service, attached
docker compose up -d # same, but detached (runs in the background)
docker compose up --build # force a rebuild of any service with a "build:" key
docker compose ps # list services and their status
docker compose logs -f web # stream logs for a single service
docker compose logs -f # stream logs for every service, interleaved
Compose prefixes every log line with the service name, so a multi-service app's combined log stream is still readable — you can see the app container's request log and the database's query log side by side without losing track of which container produced which line.
docker compose down # stop and remove containers, and the default network
docker compose down -v # also remove named volumes -- deletes persisted data!
docker compose restart web # restart a single service without touching the others
docker compose exec web sh # shell into a running service, same idea as docker exec
Notice docker compose down alone leaves your named volumes intact — a
deliberate safety default, since tearing down a stack for a code change shouldn't
silently delete a development database. Only the explicit -v flag
destroys volume data.
Modern Docker ships Compose as a plugin (docker compose, two words, no hyphen), replacing the older standalone docker-compose Python tool. Both accept nearly the same commands, but if you see a tutorial using the hyphenated form, know it's the legacy tool — the plugin form is what current Docker installs and CI runners provide by default.
3. Service Dependencies & Compose Networks
depends_on controls start order, not readiness — by
default, Compose only waits for a dependency's container to start, not for the
service inside it to actually be ready to accept connections. A plain Postgres
container reports "started" the instant its process launches, well before it's
actually accepting queries, which is exactly the kind of race condition that makes
an app crash on its first connection attempt right after docker compose up.
The healthcheck block on the db service and the
condition: service_healthy under web's
depends_on fix this properly: Compose runs pg_isready
every 5 seconds and won't consider db healthy — and won't start
web — until Postgres is actually ready to accept connections.
$ docker compose ps
NAME IMAGE COMMAND STATUS
myapp-db-1 postgres:16-alpine "docker-entrypoint.s…" Up 12 seconds (healthy)
myapp-cache-1 redis:7-alpine "docker-entrypoint.s…" Up 12 seconds
myapp-web-1 myapp-web "node dist/server.js" Up 8 seconds
Compose creates a single default network for the whole stack (or, as shown above,
you can name one explicitly with a top-level networks: block) and gives
every service DNS resolution by its service name — the same name-based discovery you
set up manually with docker network create in Week 3, except Compose
wires it up automatically for every service listed in the file. Inside the
web container, the hostname db and cache
simply resolve, no IP addresses or manual network commands required.
Any service another service depends on for correctness — a database, a message queue, a cache your app can't function without — should have a real healthcheck, not just rely on depends_on's default start-order behavior. It's a small addition that eliminates an entire class of flaky "works on retry" bugs, both locally and in the CI pipeline you'll build in Week 6.
4. Named Volumes & Persistence
The top-level volumes: block declares named volumes exactly like
docker volume create did in Week 3, and a service references one under
its own volumes: key with the same name:path syntax you
used with docker run -v. Compose creates the volume automatically the
first time the stack comes up if it doesn't already exist.
services:
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data # named volume: survives "compose down"
web:
build: .
volumes:
- ./src:/app/src # bind mount: live-reload during local dev
volumes:
pgdata:
# empty block = Compose manages this volume with default settings
This mirrors the distinction from Week 3 exactly: the database gets a named volume
because its data must outlive any single container recreation, while the app service
can use a bind mount to your local ./src directory purely for
development convenience — editing a file on your host shows up inside the running
container immediately, without a rebuild.
docker volume ls # see volumes Compose created, prefixed with the project name
docker volume inspect myapp_pgdata # where it actually lives on disk, and its driver
docker compose down # containers gone, pgdata volume untouched
docker compose up -d # new db container, same pgdata volume -- same data
Compose prefixes every resource it creates with the project name — normally the directory the docker-compose.yml lives in — so pgdata in the file becomes myapp_pgdata on disk. If two Compose projects both declare a volume called pgdata from different directories, they get separate, non-colliding volumes automatically; this is also why moving or renaming the project directory can silently point you at a "new," empty volume instead of the old one.
5. Hands-on Exercise
Compose a three-service app with a real healthcheck
Rebuild last week's manually networked app + database setup as a single declarative Compose file, and add a cache service alongside it.
Requirements:
- Reuse the multi-stage
Dockerfilefrom Week 3's exercise (or write a small one) for awebservice that reads an environment variable for its database connection string. - Write a
docker-compose.ymldefining three services:web(built from your Dockerfile),db(postgres:16-alpinewith a named volume), andcache(redis:7-alpine). - Add a
healthchecktodbusingpg_isready, and makeweb'sdepends_onusecondition: service_healthyfor the database. - Put all three services on one explicitly named network and confirm from inside
web(viadocker compose exec web sh) that bothdbandcacheresolve by hostname. - Run
docker compose up -d, confirm all three services show as healthy/running withdocker compose ps, then rundocker compose downfollowed bydocker compose up -dagain and confirm your database data persisted. - Finally, run
docker compose down -vand confirm the named volume — and the data in it — is actually gone, so you've seen both behaviors deliberately.
If web crashes on startup with a connection-refused error, check whether you actually wired condition: service_healthy under depends_on — a bare depends_on: [db] list only waits for the container to start, not for Postgres inside it to be ready, which is the exact race condition this section covers.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Your web service lists depends_on: [db] with no healthcheck, and it still occasionally crashes with a connection-refused error right after docker compose up. Why?
Your web service lists depends_on: [db] with no healthcheck, and it still occasionally crashes with a connection-refused error right after docker compose up. Why?
Plain depends_on only guarantees the dependency's container has started, not that the service inside it is actually ready to accept connections — Postgres reports "started" the moment its process launches, before it finishes initializing and starts accepting queries. web can start and try to connect during that gap, which is a race condition, not a guaranteed failure, so it only crashes "occasionally."
Q2
After docker compose down and docker compose up -d again, your database still has all its data. After docker compose down -v, it doesn't. What's the difference?
After docker compose down and docker compose up -d again, your database still has all its data. After docker compose down -v, it doesn't. What's the difference?
docker compose down removes containers and the default network but deliberately leaves named volumes alone, so the new db container reattaches to the same volume and sees the same data. The -v flag explicitly opts into also removing named volumes, which deletes the underlying data — it's an intentional safety default that keeps a routine restart from silently wiping a development database.
Q3
Inside the web container, why does the hostname db resolve to the database container without any manual network configuration?
Inside the web container, why does the hostname db resolve to the database container without any manual network configuration?
Compose automatically creates a network for the project and attaches every service in the file to it, giving each service DNS resolution by its service name — the exact mechanism you set up by hand with docker network create and --network flags in Week 3, just applied automatically to every service Compose manages instead of requiring one command per container.
Q4
Why should a database's data directory be a named volume in docker-compose.yml, but a local source-code mount for live-reload during development be a bind mount instead?
Why should a database's data directory be a named volume in docker-compose.yml, but a local source-code mount for live-reload during development be a bind mount instead?
A named volume is managed entirely by Docker and portable across machines, which is what you want for data that must persist correctly regardless of the host's filesystem layout — exactly the property a database needs. A bind mount ties the container to a specific host path, which is undesirable for production data but is exactly the point for local development: it lets you edit source on the host and see the change reflected instantly inside the running container.