View on GitHub

Containers & Kubernetes Tutorial

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

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

  1. 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 --wait returned only when the container was Healthy, not merely started. “That word comes from the healthcheck: block — --wait blocks until every service is healthy or the timeout expires.”

  2. Add the API — same command, one file further:

    docker compose -f compose.step2.yaml up -d --wait
    docker compose ps
    
    NAME             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 hostname db, which is just the service name. And depends_on: condition: service_healthy is why it did not crash-loop while Postgres was still initialising.”

  3. The full stack, web tier included:

    docker compose up -d --wait
    docker compose ps
    

    👉 point at api losing its published port (only web publishes 8080 now) and at pinboard-web-1 appearing. Open http://localhost:8080 and pin a note.

  4. 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 -f is running so the request appears live, then Ctrl-C. “logs -f api is one service; logs -f with no argument follows all of them.”

  5. 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

  1. Three API containers:

    docker compose up -d --scale api=3
    docker compose ps api
    
    NAME             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 api publishes no host port — three containers cannot share 8081. That constraint is the first thing Kubernetes removes.”

  2. 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"])'
    done
    
    4f1a2b3c4d5e 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 api through 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 — that valid=10s in the nginx config. Compose has no health-aware load balancing, no readiness gate, no rolling update. Remember this screen in Session 07.”

  3. 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

  1. Show where the rules live — a separate file that is loaded automatically:

    cat compose.override.yaml
    

    👉 “compose.yaml plus compose.override.yaml are merged whenever you run plain docker compose. Production settings in one file, developer conveniences in the other — and the override never reaches the server.”

  2. Start watching (this takes over the pane — use the left one):

    docker compose watch
    
    Watch configuration for service "web":
      - Action sync for path "…/src/pinboard-web/html"
      - Action rebuild for path "…/src/pinboard-web/Dockerfile"
    Watch enabled
    
  3. In the right pane, edit the page:

    sed -i 's|<h1>📌 Pinboard</h1>|<h1>📌 Pinboard — edited live</h1>|' \
      ../../src/pinboard-web/html/index.html
    
    Syncing service "web" after changes were detected: index.html
    

    👉 refresh the browser: the heading changed. “action: sync copied the file into the running container. No rebuild, no restart, sub-second.”

  4. Show the other action without running it (it takes ~20 s):

    👉 point at the action: rebuild entry for ../../src/pinboard-api: “a Go source change cannot be synced — the binary has to be recompiled. watch rebuilds the image and replaces the container automatically. sync+restart is the third option, for interpreted apps that read config at start.”

  5. 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

  1. 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), the develop.watch block from the override file is merged in, and relative paths have become absolute. “config is your first debugging command — and in CI it is how you validate a file without starting anything.”

  2. Prove the override is optional:

    docker compose -f compose.yaml config --services
    docker compose config --services
    
    db
    api
    web
    

    👉 “-f switches 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.”

  3. Profiles — services that must not start by default:

    docker compose ps --services
    docker compose --profile tools config --services
    
    db
    api
    web
    ---
    db
    api
    web
    dbtools
    

    👉 “dbtools is in the file but invisible to up, because it declares profiles: [tools]. Migration jobs, seeders, admin consoles, a debug proxy — they live in the same file without ever starting by accident.”

  4. Run it on demand:

    docker compose --profile tools run --rm dbtools
    
     id |  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 for db to be healthy — depends_on applies 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

  1. 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 -8
    
    DRIVER  VOLUME NAME
    local   pinboard_pinboard-data      ← still there
    

    👉 “Containers and the network are gone. The volume is not — so the notes are still there. down is safe.”

  2. Now the destructive one:

    docker compose down -v
    docker volume ls
    docker compose up -d --wait
    curl -s localhost:8080/api/notes; echo
    
    DRIVER  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. -v removes named volumes declared in the file. Type it on purpose, never out of habit.”

  3. 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-stopped is 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