1. Your First compose.yaml
A Compose file describes a set of services — each one, roughly,
the equivalent of a docker run command, written declaratively:
services:
web:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
docker compose up # build (if needed) and start every service
docker compose up -d # same, but detached
docker compose ps # see what's running
docker compose logs -f web # follow one service's logs
docker compose down # stop and remove containers, networks (not volumes, by default)
Compose automatically creates a network for the whole project and attaches every service to it — so everything you learned about container-name DNS in Week 4 just works, for free, with zero networking config written by hand.
Compose orchestrates multiple containers on a single machine — it's the right tool for local development and small single-server deployments. It is not a substitute for a real orchestrator across multiple machines; that's exactly the gap Kubernetes fills starting Week 7.
2. Services, Networks & Volumes
A real Compose file usually names its networks and volumes explicitly, and every
service block maps closely to flags you already know from docker run:
services:
api:
build: ./api # equivalent of: docker build -t api ./api
ports:
- "3000:3000" # equivalent of: -p 3000:3000
environment:
- DATABASE_URL=postgres://postgres:devpw@db:5432/appdb
networks:
- backend
db:
image: postgres:16 # equivalent of: docker run postgres:16 (no build needed)
environment:
- POSTGRES_PASSWORD=devpw
- POSTGRES_DB=appdb
volumes:
- pg-data:/var/lib/postgresql/data
networks:
- backend
# note: no "ports" here — same "don't publish the database" rule from Week 4
networks:
backend:
volumes:
pg-data:
Notice that db has no ports section — the same
don't-publish-the-database rule from Week 4 applies exactly the same way here.
The api service reaches it at hostname db because Compose
service names resolve through the same embedded DNS as manually created networks.
Use an .env file (Compose loads it automatically) or env_file: for anything sensitive, and add .env to your .gitignore. A compose.yaml with a hardcoded production password is a secret leak waiting to happen the moment it's pushed to a public repo.
3. Startup Order & Healthchecks
depends_on alone only controls the order containers are started
in — it does not wait for the database to actually be ready to
accept connections. That gap causes a classic bug: the API container starts,
tries to connect before Postgres has finished initializing, and crashes.
services:
api:
build: ./api
depends_on:
- db # only guarantees "db" is STARTED first, not READY
db:
image: postgres:16
services:
api:
build: ./api
depends_on:
db:
condition: service_healthy # wait for db's healthcheck to actually pass
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=devpw
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
Now api genuinely waits until pg_isready succeeds inside
the db container, not just until the container process has started.
This same healthcheck concept — "don't just check if it started, check if it's
actually ready to do work" — reappears as Kubernetes readiness probes in Week 16.
A healthcheck helps at startup, but a robust app should also retry its database connection with backoff on its own — containers restart, networks blip, and "wait once at boot" isn't a complete solution on its own in a real production environment.
4. A Full Stack, One Command
Put it all together: a frontend, an API, a database and a cache, defined once, started with one command:
services:
frontend:
build: ./frontend
ports:
- "8080:80"
depends_on:
- api
api:
build: ./api
environment:
- DATABASE_URL=postgres://postgres:devpw@db:5432/appdb
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=devpw
- POSTGRES_DB=appdb
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 5
cache:
image: redis:7-alpine
volumes:
pg-data:
docker compose up -d --build # rebuild any changed images, start everything
docker compose ps # four services, all healthy
docker compose down # tear the whole thing down cleanly
A new teammate should be able to clone a repo and run docker compose up to get a fully working local environment — frontend, API, database, cache — with zero manual setup steps or "install these seven things first" wiki pages.
5. Hands-on Exercise
Compose-ify your Week 4 two-container app, then add a third service
Convert last week's manually-wired API + database into a proper Compose file, then extend it and prove the healthcheck actually matters.
Part 1 — Convert to Compose:
- Write a
compose.yamlfor your Week 4 API + database setup: two services, a named volume for the database, environment variables for the connection string. - Bring it up with
docker compose up -d --buildand confirm the app works exactly as it did with manualdocker runcommands. - Run
docker compose down, thendocker compose up -dagain, and confirm your database data is still there (this proves the named volume survived the teardown).
docker compose down -v (with the extra -v) additionally removes named volumes — useful for a truly clean slate, but be intentional about when you use it, since it's how you'd lose that persisted data on purpose.
Part 2 — Add a cache and prove the healthcheck matters:
- Add a third service, a Redis cache, with no published port, on the same network as your API.
- Add a Postgres healthcheck and a
depends_on: condition: service_healthyon your API service, as shown in Section 3. - Deliberately break it: remove the healthcheck and condition, and add an artificial delay to your database's startup (or just observe with fresh eyes — Postgres itself takes a few seconds to become ready on a first run with a fresh volume). Watch your API's logs for a connection-refused error at startup.
- Restore the healthcheck and condition, tear everything down including volumes, and bring it up fresh again — confirm the connection-refused error no longer happens, and explain in your own words why.
A fresh Postgres volume runs first-time database initialization, which takes noticeably longer than a normal restart — that's exactly the window where depends_on without a healthcheck condition tends to fail, and it's a great natural reproduction of the bug without needing to fake anything.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why isn't plain depends_on enough to guarantee a database is ready before a dependent service starts?
Why isn't plain depends_on enough to guarantee a database is ready before a dependent service starts?
Plain depends_on only sequences container start order — it starts the database's container process first, but doesn't wait for that process to finish initializing and actually accept connections. A healthcheck plus a condition: service_healthy is needed to wait for genuine readiness, not just a started process.
Q2
Why shouldn't you commit real passwords directly into a compose.yaml file?
Why shouldn't you commit real passwords directly into a compose.yaml file?
compose.yaml is typically committed to version control, so hardcoded secrets end up in Git history — retrievable forever, even if later removed, and especially dangerous in a public repository. Use an .env file (gitignored) or a proper secrets manager instead.
Q3
Does docker compose down remove named volumes by default?
Does docker compose down remove named volumes by default?
No — by default it only removes containers and networks, leaving named volumes (and their data) intact. You must explicitly pass -v (docker compose down -v) to also remove volumes, which is a deliberate safety default so a routine teardown doesn't silently destroy persisted data.
Q4
Is Docker Compose a substitute for Kubernetes in a multi-machine production deployment?
Is Docker Compose a substitute for Kubernetes in a multi-machine production deployment?
No. Compose orchestrates multiple containers on a single host — it has no concept of scheduling work across multiple machines, self-healing by rescheduling failed workloads elsewhere, or cluster-wide load balancing. It's excellent for local development and small single-server setups, but a genuinely distributed, resilient deployment needs a real orchestrator like Kubernetes.