View on GitHub

Containers & Kubernetes Tutorial

Lab 04 – Pinboard in one file

Table of Contents

Goals

Pre-requisites

Continuity. In Lab 03 you wired Pinboard together by hand: docker network create, docker volume create, three docker run commands 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.0 is 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:

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, not pinboard-data: Compose prefixes volumes and networks with the project name. Your hand-made Lab 03 volume pinboard-data is a different, untouched volume — check with docker 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:

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. .env is read by the Compose CLI, not injected into containers. A variable lands in a container only if a service mentions it (environment:) or you use env_file:. And .env belongs in .gitignore — commit a .env.example instead, like src/.env.example in 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? Use grep: 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. watch is 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 (default 10s), draining in-flight requests. The same behaviour is what terminationGracePeriodSeconds controls 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

  1. Prove the ordering guarantee. Set POSTGRES_PASSWORD to a wrong value in .env for the db service only, run docker compose up -d --wait, and watch the API never start because db never becomes healthy. Then read the failure message of --wait.
  2. Make the version a variable. Change the API service to image: pinboard-api:${API_TAG:-1.0}, then run API_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 with docker compose up -d --wait.
  3. Second override file. Create compose.ci.yaml that removes the published port and sets restart: "no", and render it with docker compose -f compose.yaml -f compose.ci.yaml config. Note that -f replaces the automatic compose.override.yaml merge — with explicit -f flags, nothing is implicit.

Conclusion

What you have now

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.

Next: Lab 05 – Pinboard meets Kubernetes.