Lesson 13 Docker & HTTP Transport
Theme:

Solutions — Lesson 13

Reference answers for Lesson 13's three homework tasks · Docker healthcheck, Streamable HTTP probe, image-size experiment

Each solution below shows the worked answer for one homework task, followed by a "common mistake" callout that captures the failure modes we saw in earlier drafts. Your exact byte counts, container IDs, and build durations will obviously differ from the samples shown here. Compare against the self-check criteria from the homework before you call a task done.

Solution to Task 1 — Docker healthcheck for pkb

The healthcheck lives on the pkb service and is consumed by caddy's depends_on clause. Because the node:20-alpine runtime image does not ship curl, the probe uses Node's built-in fetch (available since Node 18) to avoid adding a new package to the image just for a one-line health probe.

services:
  pkb:
    build: .
    environment:
      - PKB_TRANSPORT=http
      - PORT=8080
    expose:
      - "8080"
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://localhost:8080/healthz').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 2s
      retries: 3
      start_period: 5s
    restart: unless-stopped

  caddy:
    image: caddy:2-alpine
    depends_on:
      pkb:
        condition: service_healthy
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    restart: unless-stopped

volumes:
  caddy_data:
  caddy_config:

Self-check evidence on a successful run:

$ docker compose up -d
[+] Running 3/3
 - Container pkb-pkb-1    Healthy   12.4s
 - Container pkb-caddy-1  Started   12.6s
 ...

$ docker inspect pkb-pkb-1 --format='{{.State.Health.Status}}'
healthy

Reading this solution:

Common mistake

Using curl in the healthcheck without confirming curl is in the image — node:20-alpine doesn't ship it. The healthcheck reports unhealthy forever because curl: not found exits 127. Use Node's built-in fetch (Node 18+) as shown, or wget (which is on alpine: wget -q --spider http://localhost:8080/healthz || exit 1). If you really want curl, add RUN apk add --no-cache curl to the runtime stage of the Dockerfile and accept the ~3 MB cost.

Solution to Task 2 — Streamable HTTP probe summary

This task is a reading exercise. The 12–20-line summary should cite at least one concrete SDK symbol and ground each claim in something you actually observed in the source. Sample summary:

The Streamable HTTP transport (StreamableHTTPServerTransport in @modelcontextprotocol/sdk/server/streamableHttp.js) collapses SSE's two-route design into a single POST / endpoint that uses HTTP chunked transfer-encoding for the server-to-client direction. Where SSE needs a long-lived GET /sse plus a separate POST /messages?sessionId=... to feed it inbound JSON-RPC messages, Streamable HTTP returns a chunked response from the same POST that delivered the request — request and response share one connection. Edge runtimes (Cloudflare Workers, Vercel Functions, Deno Deploy) prefer this because they enforce short connection lifetimes (typically 30–100 seconds) and limit concurrent open streams per worker; one chunked POST is friendlier to those quotas than an hour-long SSE stream. To migrate PKB-TS from SSE to Streamable HTTP, replace the SSEServerTransport instantiation in the GET /sse handler and the POST /messages handler with a single POST / route that constructs a StreamableHTTPServerTransport per request, calls server.connect(transport), and lets the SDK drive the chunked response. No tool code changes — tools.ts and schemas.ts stay byte-identical because the transport boundary is the only thing moving.

Reading this summary:

Common mistake

Summarising Streamable HTTP as "WebSockets" — it is not. WebSockets are a separate protocol (RFC 6455) with an Upgrade handshake and a binary framing layer. Streamable HTTP is plain HTTP/1.1 or HTTP/2 with Transfer-Encoding: chunked; nothing upgrades, nothing frames. Reading the source is the point of this task — grep streamableHttp.js for res.write and res.end and you'll see vanilla Node HTTP. If your summary mentions ws:// or wss://, you didn't read the source. The other common variant: claiming SSE is "deprecated" — it isn't; both transports are supported, SSE is just older and more widely deployed in 2026.

Solution to Task 3 — Image-size reduction experiment

The three Dockerfile variants compared. Real numbers vary by Docker version, base-image release, and how much of node_modules the build needs — but the ordering is what matters and it should match the table below.

Variant 1 — Multi-stage, node:20-alpine runtime (baseline):

# Builder stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build

# Runtime stage
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "dist/server.js"]

Variant 2 — Single-stage, node:20 only:

FROM node:20
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build
EXPOSE 8080
CMD ["node", "dist/server.js"]

Variant 3 — Multi-stage, node:20-bookworm-slim runtime:

FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build

FROM node:20-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "dist/server.js"]

Build each, then run docker images | grep pkb-ts. Sample results table (real numbers vary):

$ docker images | grep pkb-ts
pkb-ts-alpine        latest   a1b2c3d4   2 min ago    95.4 MB
pkb-ts-fullnode      latest   e5f6a7b8   1 min ago    1.21 GB
pkb-ts-bookworm      latest   c9d0e1f2   30 sec ago   142  MB

Written up as a markdown table:

| Variant                                    | Final image size | Build time (cold) |
|--------------------------------------------|------------------|-------------------|
| Multi-stage, `node:20-alpine` runtime      | ~95 MB           | ~45 s             |
| Single-stage, `node:20` only               | ~1.2 GB          | ~30 s             |
| Multi-stage, `node:20-bookworm-slim`       | ~142 MB          | ~50 s             |

Reading the results:

Common mistake

Measuring with docker images after a partial build cache hit — sizes are accurate but build times mislead. Use docker builder prune -af between variants for a fair cold-build comparison. A second common variant: forgetting --omit=dev in the runtime npm ci step, which silently doubles the runtime image because TypeScript and @types/* ride along into production. Always pair --omit=dev with npm cache clean --force on the same layer so the cache directory isn't written to disk before deletion.

What you learned

Lesson 14 makes the server safe to expose: bearer-token auth identifies the caller, per-tenant note isolation prevents cross-user leaks, and a token-bucket rate limiter caps abuse. The Dockerfile and Caddyfile from this lesson stay untouched — only server.ts and the new auth.ts / store.ts / rateLimit.ts modules pick up the multi-tenant logic.