Session 04 demos – Docker Compose
Demos 1–2 belong to Block A (the Compose Specification, up --wait, scaling),
Demos 3–5 to Block B (the developer loop, overrides, profiles, teardown).
Files next to this one:
| File | Used in |
|---|---|
compose.step1.yaml |
Demo 1 — db only |
compose.step2.yaml |
Demo 1 — db + api |
compose.yaml |
Demos 1–5 — the full stack (= src/compose.yaml, paths rewritten, plus a tools profile) |
compose.override.yaml |
Demo 3 — develop.watch rules, loaded automatically |
Before the session
~5 minutes:
cd ~/docker-kubernetes-training/demos/session04
docker pull postgres:17-alpine
docker compose build # builds pinboard-api:1.0 and pinboard-web:1.0
docker compose down -v # start from nothing
- Terminal: two panes — left for
docker compose …, right parked onwatch -n1 'docker compose ps'(ordocker compose logs -f). - Browser on
http://localhost:8080, plus free ports 8080 and 8081. docker compose versionmust print v2.x — everything here usesdocker compose(a plugin), never the old hyphenateddocker-composebinary.- No
.envfile is needed: every variable in these files has a${VAR:-default}. Say so out loud — Lab 04 is where students create the.env. - Keep
src/pinboard-web/html/index.htmlunmodified (Demo 3 edits it live; the Reset section puts it back).
Demo 1: from one service to a stack (≈ 8 min)
What to show / say
Everything we typed by hand in Lab 03 — network, volume, names, start order — is
one declarative file, and up is idempotent.
Steps
-
Read the smallest possible file out loud, then start it:
cat compose.step1.yaml docker compose -f compose.step1.yaml up -d --wait[+] Running 3/3 ✔ Network pinboard_default Created ✔ Volume "pinboard_pinboard-data" Created ✔ Container pinboard-db-1 Healthy👉 three things to point at: no
version:key (it has been obsolete since Compose v2 — delete it wherever you still see it); Compose created the network and the volume by itself; and--waitreturned only when the container was Healthy, not merely started. “That word comes from thehealthcheck:block —--waitblocks until every service is healthy or the timeout expires.” -
Add the API — same command, one file further:
docker compose -f compose.step2.yaml up -d --wait docker compose psNAME IMAGE SERVICE STATUS PORTS pinboard-api-1 pinboard-api:1.0 api Up 4 seconds 0.0.0.0:8081->8080/tcp pinboard-db-1 postgres:17-alpine db Up 2 minutes (healthy) 5432/tcp👉 point at the db not being recreated — “same project name, so Compose reconciles: it added what was missing and left the rest alone. That is the same desired-state idea Kubernetes is built on.”
curl -s localhost:8081/api/info; echo{"app":"pinboard-api","greeting":"Welcome to Pinboard (Compose)","hostname":"…","store":"postgres","theme":"emerald","uptime":"18s","version":"1.0"}👉
"store":"postgres"— “it found the database at the hostnamedb, which is just the service name. Anddepends_on: condition: service_healthyis why it did not crash-loop while Postgres was still initialising.” -
The full stack, web tier included:
docker compose up -d --wait docker compose ps👉 point at
apilosing its published port (onlywebpublishes 8080 now) and atpinboard-web-1appearing. Openhttp://localhost:8080and pin a note. -
Logs — all services, colour-coded, one stream:
docker compose logs --tail 5 docker compose logs -f api👉 pin a note in the browser while
logs -fis running so the request appears live, then Ctrl-C. “logs -f apiis one service;logs -fwith no argument follows all of them.” -
A shell inside a service, by service name — not container name:
docker compose exec db psql -U pinboard -d pinboard -c '\dt' -c 'SELECT id, author, text FROM notes;'List of relations Schema | Name | Type | Owner --------+-------+-------+---------- public | notes | table | pinboard id | author | text ----+----------+----------------------- 1 | postgres | Hello from init.sql 👋 2 | anonymous| pinned live👉 “Row 1 came from
db/init.sql, mounted into/docker-entrypoint-initdb.d/. Row 2 is the note I just pinned in the browser — through nginx, through the API, into Postgres. The whole stack, from one file.”
Demo 2: --scale and load balancing by DNS (≈ 6 min)
What to show / say
Compose can run N copies of a service, and nginx spreads requests across them — a first, honest look at what a Kubernetes Service will do properly.
Steps
-
Three API containers:
docker compose up -d --scale api=3 docker compose ps apiNAME IMAGE SERVICE STATUS pinboard-api-1 pinboard-api:1.0 api Up 5 minutes pinboard-api-2 pinboard-api:1.0 api Up 3 seconds pinboard-api-3 pinboard-api:1.0 api Up 3 seconds👉 “This only works because
apipublishes no host port — three containers cannot share8081. That constraint is the first thing Kubernetes removes.” -
Watch the requests spread:
for i in $(seq 1 6); do curl -s localhost:8080/api/info | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["hostname"], d["version"], d["store"])' done4f1a2b3c4d5e 1.0 postgres 7a8b9c0d1e2f 1.0 postgres 2c3d4e5f6a7b 1.0 postgres 4f1a2b3c4d5e 1.0 postgres 7a8b9c0d1e2f 1.0 postgres 2c3d4e5f6a7b 1.0 postgres👉 point at the rotating hostname (= the container ID). “nginx resolves
apithrough Docker’s embedded DNS, which returns all three IPs, and round-robins between them. It can take up to ten seconds to notice new replicas — thatvalid=10sin the nginx config. Compose has no health-aware load balancing, no readiness gate, no rolling update. Remember this screen in Session 07.” -
Scale back down:
docker compose up -d --scale api=1 docker compose ps api👉 “Scaling down just kills containers — whatever they were serving. Again: Kubernetes does this properly, with termination grace periods and endpoint removal first.”
Demo 3: docker compose watch — the developer loop (≈ 6 min)
What to show / say
Edit a file on the host, see it in the running stack, without a rebuild or a restart — and without a bind mount in your production file.
Steps
-
Show where the rules live — a separate file that is loaded automatically:
cat compose.override.yaml👉 “
compose.yamlpluscompose.override.yamlare merged whenever you run plaindocker compose. Production settings in one file, developer conveniences in the other — and the override never reaches the server.” -
Start watching (this takes over the pane — use the left one):
docker compose watchWatch configuration for service "web": - Action sync for path "…/src/pinboard-web/html" - Action rebuild for path "…/src/pinboard-web/Dockerfile" Watch enabled -
In the right pane, edit the page:
sed -i 's|<h1>📌 Pinboard</h1>|<h1>📌 Pinboard — edited live</h1>|' \ ../../src/pinboard-web/html/index.htmlSyncing service "web" after changes were detected: index.html👉 refresh the browser: the heading changed. “
action: synccopied the file into the running container. No rebuild, no restart, sub-second.” -
Show the other action without running it (it takes ~20 s):
👉 point at the
action: rebuildentry for../../src/pinboard-api: “a Go source change cannot be synced — the binary has to be recompiled.watchrebuilds the image and replaces the container automatically.sync+restartis the third option, for interpreted apps that read config at start.” -
Stop the watcher with Ctrl-C and revert the edit:
sed -i 's|<h1>📌 Pinboard — edited live</h1>|<h1>📌 Pinboard</h1>|' \ ../../src/pinboard-web/html/index.html
Demo 4: config, overrides and profiles (≈ 6 min)
What to show / say
When a stack does not behave, look at the file Compose actually assembled — not the one you wrote.
Steps
-
The rendered, merged, interpolated file:
docker compose config | head -30👉 point at three things: the
${POSTGRES_PASSWORD:-…}defaults are now real values (so never paste this into a chat), thedevelop.watchblock from the override file is merged in, and relative paths have become absolute. “configis your first debugging command — and in CI it is how you validate a file without starting anything.” -
Prove the override is optional:
docker compose -f compose.yaml config --services docker compose config --servicesdb api web👉 “
-fswitches off the automatic override discovery. You can also stack them:-f compose.yaml -f compose.prod.yaml, merged left to right. That is how one repo serves dev, CI and staging.” -
Profiles — services that must not start by default:
docker compose ps --services docker compose --profile tools config --servicesdb api web --- db api web dbtools👉 “
dbtoolsis in the file but invisible toup, because it declaresprofiles: [tools]. Migration jobs, seeders, admin consoles, a debug proxy — they live in the same file without ever starting by accident.” -
Run it on demand:
docker compose --profile tools run --rm dbtoolsid | author | text ----+----------+------------------------ 2 | anonymous| pinned live 1 | postgres | Hello from init.sql 👋👉 “
run --rm= a one-shot container on the project’s network, gone when it finishes. Note it still waited fordbto be healthy —depends_onapplies here too.”
Demo 5: down vs down -v (≈ 4 min)
What to show / say
The most expensive keystroke in Compose is -v. Show it deliberately, once, so
nobody discovers it on a real database.
Steps
-
Tear the stack down and bring it back:
docker compose down docker volume ls docker compose up -d --wait curl -s localhost:8080/api/notes | python3 -m json.tool | head -8DRIVER VOLUME NAME local pinboard_pinboard-data ← still there👉 “Containers and the network are gone. The volume is not — so the notes are still there.
downis safe.” -
Now the destructive one:
docker compose down -v docker volume ls docker compose up -d --wait curl -s localhost:8080/api/notes; echoDRIVER VOLUME NAME (empty) [{"id":1,"text":"Hello from init.sql 👋","author":"postgres","createdAt":"…"}]👉 “The volume was deleted, Postgres re-initialised from scratch and re-ran
init.sql— so the only note left is the one the init script inserts. Everything the users pinned is gone.-vremoves named volumes declared in the file. Type it on purpose, never out of habit.” -
Optional bridge to Session 05 — say it while the stack is up:
👉 “Kill the API container by hand:
docker rm -f pinboard-api-1. Compose will not bring it back —restart: unless-stoppedis the daemon restarting a process, not a controller reconciling a desired state, and nothing here can move a container to another machine. That gap is what the next five sessions are about.”
Reset
Lab 04 has students write their own compose.yaml in ~/pinboard-labs/lab04/, so
the demo stack must be gone — but the built images stay.
cd ~/docker-kubernetes-training/demos/session04
# 1. Undo the live edit from Demo 3
sed -i 's|<h1>📌 Pinboard — edited live</h1>|<h1>📌 Pinboard</h1>|' \
../../src/pinboard-web/html/index.html
grep -n '<h1>' ../../src/pinboard-web/html/index.html
# 2. Stack, volumes and network
docker compose down -v --remove-orphans
docker compose -f compose.step2.yaml down -v --remove-orphans 2>/dev/null
Verify:
docker compose ls # no "pinboard" project
docker volume ls # no pinboard_pinboard-data
docker image ls 'pinboard-*' # pinboard-api:1.0 and pinboard-web:1.0 kept