Lab 04 – Pinboard in one file
Table of Contents
- Goals
- Pre-requisites
- Guide
- Step 01: Prepare the lab folder
- Step 02: The first service — the database
- Step 03: Add the API and wait for the database
- Step 04: Add the web front end
- Step 05: Move the credentials into
.env - Step 06: Day-two commands: ps, logs, exec
- Step 07: Scale the API and watch the round-robin
- Step 08: The developer loop with
docker compose watch - Step 09: A
toolsprofile for one-off containers - Step 10: Tear it down —
downvsdown -v
- Stretch goal
- Conclusion
Goals
- Describe the whole Pinboard stack (db + api + web) in a single
compose.yaml - Use
healthcheck+depends_on: condition: service_healthyso services start in the right order - Keep credentials out of the compose file with
.envand variable interpolation - Scale a service and observe client-side load balancing
- Edit the web page and see it live with
docker compose watch, then clean up withdownvsdown -v
Pre-requisites
- Have finished Lab 03
- Docker Engine 28+ with Compose v2 (
docker compose version), from Lab 00 – Course setup - The course repo cloned at
~/docker-kubernetes-training
Continuity. In Lab 03 you wired Pinboard together by hand:
docker network create,docker volume create, threedocker runcommands with the right flags in the right order. It worked — and it was six commands you had to remember and repeat. This lab replaces all of them with one file you can commit. The images you built in Lab 02 (pinboard-api:1.0,pinboard-api:1.1,pinboard-web:1.0) are reused as-is, and the Docker network and volume from Lab 03 stay where they are; Compose creates its own.
Guide
Step 01: Prepare the lab folder
All the work of this lab happens in ~/pinboard-labs/lab04/.
mkdir -p ~/pinboard-labs/lab04
cd ~/pinboard-labs/lab04
Compose mounts files from the project folder, so copy the two pieces of the sample app
you need: the database bootstrap SQL, and the whole pinboard-web folder (you will
build and live-edit it in Step 08).
cp -r ~/docker-kubernetes-training/src/db ./db
cp -r ~/docker-kubernetes-training/src/pinboard-web ./pinboard-web
ls -R . | head -20
Check that the images from Lab 02 are still there and that nothing is running:
docker image ls 'pinboard-*'
docker ps
REPOSITORY TAG IMAGE ID CREATED SIZE
pinboard-api 1.0 0f7a2b3c4d5e 2 hours ago 12.8MB
pinboard-api 1.1 5e6f7a8b9c0d 2 hours ago 12.8MB
pinboard-api 1.2-broken 7a8b9c0d1e2f 2 hours ago 12.8MB
pinboard-web 1.0 2b3c4d5e6f70 2 hours ago 58.4MB
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Note. If
pinboard-api:1.0is missing, rebuild it:docker build -t pinboard-api:1.0 --build-arg APP_VERSION=1.0 ~/docker-kubernetes-training/src/pinboard-api
Step 02: The first service — the database
Build the file one service at a time so you can see each piece work. Create
compose.yaml:
name: pinboard
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: pinboard
POSTGRES_PASSWORD: pinboard-secret
POSTGRES_DB: pinboard
volumes:
- pinboard-data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
volumes:
pinboard-data:
Three things deserve a second look:
- There is no
version:key. It was a Compose v1 artefact; the Compose Specification dropped it and Compose v2 warns if you keep it. name: pinboardpins the project name (otherwise the folder name,lab04, is used). Every container, network and volume is prefixed with it.$${POSTGRES_USER}has a doubled dollar. A single$would be interpolated by Compose itself (and would be empty); doubling it passes a literal$POSTGRES_USERinto the container, where the shell runningpg_isreadyexpands it.
Start it:
docker compose up -d --wait
[+] Running 3/3
✔ Network pinboard_default Created 0.1s
✔ Volume "pinboard_pinboard-data" Created 0.0s
✔ Container pinboard-db-1 Healthy 11.4s
--wait does not return until every service is healthy (for services with a
healthcheck) or running (for the others). Without it, up -d returns as soon as the
containers are created — which is why so many CI scripts have a sleep 10 in them.
Look at the healthcheck through docker compose ps:
docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
pinboard-db-1 postgres:17-alpine "docker-entrypoint.s…" db 22 seconds ago Up 21 seconds (healthy) 5432/tcp
Note. The volume is called
pinboard_pinboard-data, notpinboard-data: Compose prefixes volumes and networks with the project name. Your hand-made Lab 03 volumepinboard-datais a different, untouched volume — check withdocker volume ls.
Step 03: Add the API and wait for the database
Add the api service to compose.yaml, between db and the volumes: block:
api:
image: pinboard-api:1.0
environment:
DATABASE_URL: postgres://pinboard:pinboard-secret@db:5432/pinboard
APP_THEME: emerald
APP_GREETING: Welcome to Pinboard (Compose)
depends_on:
db:
condition: service_healthy
deploy:
resources:
limits:
memory: 128M
restart: unless-stopped
Notes on what you just wrote:
image:and nobuild:. The image already exists locally from Lab 02, so Compose will use it. (A service can have both —build:says how to make the image,image:says how to tag it.)- The API connects to the database at the host name
db— the service name. Compose puts every service on the project networkpinboard_default, where Docker’s embedded DNS resolves service names. Exactly what you did by hand with--network pinboard-netin Lab 03. depends_on … condition: service_healthyis the part plaindocker runcannot do: the API container is not even created until Postgres answerspg_isready. Plaindepends_on: [db]would only wait for the container to start, which is not the same thing at all.deploy.resources.limits.memoryis the Compose equivalent of--memory 128m.
docker compose up -d --wait
docker compose ps
[+] Running 2/2
✔ Container pinboard-db-1 Healthy 0.5s
✔ Container pinboard-api-1 Started 0.9s
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
pinboard-api-1 pinboard-api:1.0 "/pinboard-api" api 6 seconds ago Up 5 seconds 8080/tcp
pinboard-db-1 postgres:17-alpine "docker-entrypoint.s…" db 3 minutes ago Up 3 minutes (healthy) 5432/tcp
The API has no (healthy) marker: its image is distroless, with no shell and no curl,
so it carries no HEALTHCHECK. The web container in the next step does have one.
Step 04: Add the web front end
Add the last service, again before volumes::
web:
image: pinboard-web:1.0
environment:
API_URL: http://api:8080
ports:
- "8080:8080"
depends_on:
- api
restart: unless-stopped
Only web publishes a port. db and api are reachable inside the project network on
5432 and 8080 but are not exposed on your laptop — the smallest possible attack surface,
and the same shape you will build in Kubernetes with Services and one Ingress.
docker compose up -d --wait
curl -s localhost:8080/api/info
{"app":"pinboard-api","greeting":"Welcome to Pinboard (Compose)","hostname":"a4f83c9e1b27","store":"postgres","theme":"emerald","uptime":"12s","version":"1.0"}
"store":"postgres" proves the API found the database. Open http://localhost:8080 in
the browser and pin a note; then check that it really landed in Postgres:
curl -s localhost:8080/api/notes
[{"id":2,"text":"Compose is one file","author":"you","createdAt":"2026-05-04T09:15:02Z"},
{"id":1,"text":"Hello from init.sql 👋","author":"postgres","createdAt":"2026-05-04T09:12:44Z"}]
The API returns the newest note first, so the one at the bottom (id: 1) is the one
that came from db/init.sql, mounted into /docker-entrypoint-initdb.d/ — Postgres runs
everything in that folder the first time it initialises an empty data directory.
Check yourself: you delete the note, then run docker compose down and up -d again. Does the init.sql note come back?
No. `/docker-entrypoint-initdb.d/` scripts run **only when the data directory is empty**.
`down` removes the containers but keeps the volume `pinboard_pinboard-data`, so Postgres
starts on an existing database and skips initialisation entirely. Only `down -v` (Step 10)
wipes the volume and makes `init.sql` run again.
Step 05: Move the credentials into .env
Hard-coded passwords in a file you commit is exactly what you do not want. Compose
interpolates ${VAR} from the environment and from a .env file in the project folder.
Create .env:
# Lab 04 — values interpolated into compose.yaml (${VAR}).
# `.env` is read by the Compose CLI itself; it is NOT passed to the containers
# unless a service references the variable. Never commit real credentials.
POSTGRES_USER=pinboard
POSTGRES_PASSWORD=pinboard-secret
POSTGRES_DB=pinboard
APP_THEME=emerald
APP_GREETING=Welcome to Pinboard (Compose)
Now replace the literals in compose.yaml with interpolations. The ${VAR:-default}
form keeps the file working even when .env is missing:
environment:
POSTGRES_USER: ${POSTGRES_USER:-pinboard}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pinboard-secret}
POSTGRES_DB: ${POSTGRES_DB:-pinboard}
and, in the api service:
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-pinboard}:${POSTGRES_PASSWORD:-pinboard-secret}@db:5432/${POSTGRES_DB:-pinboard}
APP_THEME: ${APP_THEME:-emerald}
APP_GREETING: ${APP_GREETING:-Welcome to Pinboard (Compose)}
Ask Compose what it actually understood — this is the single most useful debugging command in Compose:
docker compose config
name: pinboard
services:
api:
depends_on:
db:
condition: service_healthy
required: true
environment:
APP_GREETING: Welcome to Pinboard (Compose)
APP_THEME: emerald
DATABASE_URL: postgres://pinboard:pinboard-secret@db:5432/pinboard
image: pinboard-api:1.0
...
config renders the merged, interpolated model: every override file applied, every
variable substituted, every default filled in. If something in Compose surprises you,
run this first.
Warning.
.envis read by the Compose CLI, not injected into containers. A variable lands in a container only if a service mentions it (environment:) or you useenv_file:. And.envbelongs in.gitignore— commit a.env.exampleinstead, likesrc/.env.examplein the course repo.
Recreate the stack so the new values take effect:
docker compose up -d --wait
Step 06: Day-two commands: ps, logs, exec
Compose gives you the familiar Docker verbs, scoped to the project — no container IDs to copy around.
docker compose logs -f api
api-1 | time=2026-05-04T09:15:57.113Z level=INFO msg="pinboard-api starting" version=1.0 theme=emerald store=postgres addr=:8080 hostname=a4f83c9e1b27
api-1 | time=2026-05-04T09:16:02.884Z level=INFO msg=request method=GET path=/api/info status=200 duration=412.6µs
Press Ctrl-C to stop following (the container keeps running). docker compose logs
without -f prints everything; without a service name it interleaves all services with a
colour per service.
Open a psql shell inside the database container:
docker compose exec db psql -U pinboard -d pinboard -c 'SELECT id, author, text FROM notes;'
id | author | text
----+----------+-----------------------
1 | postgres | Hello from init.sql 👋
2 | you | Compose is one file
(2 rows)
Drop the -c '…' for an interactive session (\dt, \q to leave).
docker compose top api
pinboard-api-1
UID PID PPID C STIME TTY TIME CMD
65532 184213 184190 0 09:15 ? 00:00:00 /pinboard-api
One process, PID 1 in its own namespace, running as UID 65532 — the nonroot user baked
into the distroless image.
Step 07: Scale the API and watch the round-robin
The api service publishes no ports and has no fixed container name, which is precisely
what makes it scalable:
docker compose up -d --wait --scale api=3
docker compose ps api
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
pinboard-api-1 pinboard-api:1.0 "/pinboard-api" api 4 minutes ago Up 4 minutes 8080/tcp
pinboard-api-2 pinboard-api:1.0 "/pinboard-api" api 6 seconds ago Up 5 seconds 8080/tcp
pinboard-api-3 pinboard-api:1.0 "/pinboard-api" api 6 seconds ago Up 5 seconds 8080/tcp
The name api now resolves to three IP addresses. nginx in the web container re-resolves
it (resolver … valid=10s) and follows Docker’s DNS, so requests are spread over the
three containers. The hostname field of /api/info is the container ID, so you can see
which one answered:
for i in $(seq 1 30); do curl -s localhost:8080/api/info | jq -r .hostname; sleep 1; done | uniq -c
10 a4f83c9e1b27
10 c7b21d4e8f90
10 e19a6c30b5d4
Note. No
jq? Usegrep:curl -s localhost:8080/api/info | grep -o '"hostname":"[^"]*"'.
Note. The hostname changes roughly every ten seconds rather than on every request: nginx caches the DNS answer for
valid=10s. Docker’s embedded DNS returns the three addresses in a rotating order, so over 30 seconds you see all three. This is load balancing — but coarse, DNS-based and entirely dependent on the client honouring TTLs. Kubernetes Services (Lab 07) balance per connection instead.
Map the container IDs back to Compose names:
docker ps --filter label=com.docker.compose.service=api --format ' '
e19a6c30b5d4 pinboard-api-3
c7b21d4e8f90 pinboard-api-2
a4f83c9e1b27 pinboard-api-1
Scale back down before continuing:
docker compose up -d --wait --scale api=1
Check yourself: why would --scale web=3 fail?
Because `web` publishes `8080:8080`. Only one container can hold host port 8080, so the
second one fails with `Bind for 0.0.0.0:8080 failed: port is already allocated`. A
service is only horizontally scalable if it does not claim a fixed host resource — a
published port, a `container_name:`, or an exclusive bind mount. Getting past that limit
(one address, many replicas, on many hosts) is a large part of why Kubernetes exists.
Step 08: The developer loop with docker compose watch
Compose reads compose.override.yaml automatically and merges it on top of
compose.yaml. This is the standard way to keep developer-only settings out of the
file you ship. Create compose.override.yaml:
services:
web:
build: ./pinboard-web
develop:
watch:
- action: sync
path: ./pinboard-web/html
target: /usr/share/nginx/html
- action: rebuild
path: ./pinboard-web/Dockerfile
watch needs to know how the image is built, hence the build: line — the override turns
web into a service built from your copy of the sources. action: sync copies changed
files into the running container (milliseconds, no restart); action: rebuild rebuilds
the image and recreates the container, which is what a change to the Dockerfile
requires.
Confirm the merge before running anything:
docker compose config | grep -A6 'develop:'
Now start the watcher — it runs in the foreground and prints what it does:
docker compose watch
Watch configuration for service "web":
- Action sync for path "/home/student/pinboard-labs/lab04/pinboard-web/html"
- Action rebuild for path "/home/student/pinboard-labs/lab04/pinboard-web/Dockerfile"
In a second terminal, edit the page title:
cd ~/pinboard-labs/lab04
sed -i 's|<h1>📌 Pinboard</h1>|<h1>📌 Pinboard — live edit</h1>|' pinboard-web/html/index.html
The first terminal reacts immediately:
Syncing service "web" after changes were detected: index.html
Reload http://localhost:8080 — the heading changed, and no container was rebuilt or restarted. Now touch the Dockerfile to see the other action:
touch pinboard-web/Dockerfile
Rebuilding service "web" after changes were detected...
✔ Service web Built 1.8s
Service "web" is up-to-date
Stop the watcher with Ctrl-C (the containers keep running).
Warning.
watchis a development tool. It syncs from your working copy into a running container, so the container’s content no longer matches the image. Never rely on it for anything you deploy — build an image and ship that.
Step 09: A tools profile for one-off containers
Not every service should start with up. profiles: marks services as opt-in. Add the
two below to compose.yaml (before the volumes: block):
psql:
image: postgres:17-alpine
profiles: ["tools"]
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:-pinboard-secret}
entrypoint:
["psql", "-h", "db", "-U", "${POSTGRES_USER:-pinboard}", "-d", "${POSTGRES_DB:-pinboard}"]
depends_on:
db:
condition: service_healthy
adminer:
image: adminer:5
profiles: ["tools"]
environment:
ADMINER_DEFAULT_SERVER: db
ports:
- "8081:8080"
depends_on:
db:
condition: service_healthy
A plain docker compose up -d ignores both — check with docker compose ps. To use the
psql client as a throwaway container:
docker compose run --rm psql -c 'SELECT count(*) AS notes FROM notes;'
notes
-------
2
(1 row)
run --rm starts one container for one command and deletes it afterwards; it does not
publish the service’s ports and does not join the up lifecycle. This is the Compose
answer to “I need a client container on the app network for two minutes”.
To get the graphical variant, start the profile explicitly:
docker compose --profile tools up -d adminer
[+] Running 1/1
✔ Container pinboard-adminer-1 Started 0.6s
Open http://localhost:8081 (system PostgreSQL, server db, user/password/database
pinboard) and browse the notes table. Then stop it again:
docker compose stop adminer && docker compose rm -f adminer
[+] Stopping 1/1
✔ Container pinboard-adminer-1 Stopped 0.3s
Going to remove pinboard-adminer-1
[+] Removing 1/1
✔ Container pinboard-adminer-1 Removed 0.1s
Step 10: Tear it down — down vs down -v
docker compose down
[+] Running 4/4
✔ Container pinboard-web-1 Removed 0.4s
✔ Container pinboard-api-1 Removed 10.2s
✔ Container pinboard-db-1 Removed 0.3s
✔ Network pinboard_default Removed 0.2s
down removes containers and networks — not volumes. Prove it:
docker volume ls --filter name=pinboard
docker compose up -d --wait
curl -s localhost:8080/api/notes | head -c 120
The notes are still there: the data lived in pinboard_pinboard-data, which survived.
Note. The API took ~10 s to stop. That is not a hang: the Go process traps SIGTERM and shuts down gracefully within
SHUTDOWN_GRACE(default10s), draining in-flight requests. The same behaviour is whatterminationGracePeriodSecondscontrols in Kubernetes.
Now do the destructive version:
docker compose down -v
docker volume ls --filter name=pinboard
[+] Running 4/4
✔ Container pinboard-web-1 Removed 0.4s
✔ Container pinboard-api-1 Removed 10.2s
✔ Container pinboard-db-1 Removed 0.3s
✔ Volume pinboard_pinboard-data Removed 0.1s
✔ Network pinboard_default Removed 0.2s
DRIVER VOLUME NAME
local pinboard-data
Only the hand-made Lab 03 volume pinboard-data remains — Compose only ever touches what
it created. Keep it, and keep your images: Lab 05 loads them into Kubernetes.
Stretch goal
- Prove the ordering guarantee. Set
POSTGRES_PASSWORDto a wrong value in.envfor thedbservice only, rundocker compose up -d --wait, and watch the API never start becausedbnever becomes healthy. Then read the failure message of--wait. - Make the version a variable. Change the API service to
image: pinboard-api:${API_TAG:-1.0}, then runAPI_TAG=1.1 APP_THEME=amber docker compose up -d --wait— a shell variable beats.env, so you get the 1.1 image with an amber header without editing a file. Reload the page and watch the header colour change; that is the “visible rolling update” you will do properly in Lab 06. Put it back withdocker compose up -d --wait. - Second override file. Create
compose.ci.yamlthat removes the published port and setsrestart: "no", and render it withdocker compose -f compose.yaml -f compose.ci.yaml config. Note that-freplaces the automaticcompose.override.yamlmerge — with explicit-fflags, nothing is implicit.
Conclusion
What you have now
~/pinboard-labs/lab04/compose.yaml— the whole Pinboard stack in one declarative file: healthcheck, dependency condition, named volume, init SQL, resource limit, a published port on the web tier only, and atoolsprofile..env+ interpolation, so no credential is written in the compose file.compose.override.yamlwithdevelop.watchfor a sync-on-save developer loop.- The muscle memory for
up -d --wait,ps,logs -f,exec,config,run --rm,downanddown -v.
Clean-up state. Containers, the Compose network and the Compose volume are gone.
Your images (pinboard-api:1.0, 1.1, 1.2-broken, pinboard-web:1.0) and the Lab 03
volume pinboard-data are kept — the next lab needs the images.
What Compose still cannot do, and why the rest of the course exists: it runs on one
host, it does not reschedule containers when that host dies, --scale is manual, there
are no rolling updates and no readiness gate, and .env is not a secret store. That list
is the job description of Kubernetes.