Lesson 13 Docker & HTTP Transport
Theme:

Lab 13 — Run PKB-TS in HTTP mode, build the Docker image, bring up Caddy

Lesson 13 · MCP Servers · ~60 min, code changes committed under mcp-servers/sample-server/typescript/

This lab takes the stdio TS server from L12 and turns it into something other people on the internet can reach. You will add an HTTP+SSE transport behind a single env-var switch, dockerize the build with a multi-stage Dockerfile, and front it with Caddy for automatic TLS. By the end you have a PKB-TS image you can docker run anywhere and (optionally) a real HTTPS endpoint at a domain you control.

Prereqs

Step 1 — Add the HTTP transport switch in src/server.ts

The goal of this step is a single env-var switch that picks the transport at startup; tool code is untouched. Install the two new deps first:

npm install express
npm install -D @types/express

Open src/server.ts. Above main(), import the SSE transport and Express:

import express from "express";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";

Replace the L12 main() body with a branch on PKB_TRANSPORT:

async function main(): Promise<void> {
  const mode = process.env.PKB_TRANSPORT ?? "stdio";

  if (mode === "stdio") {
    const transport = new StdioServerTransport();
    await server.connect(transport);
    logger.info({ phase: "ready", transport: "stdio" });
    return;
  }

  if (mode === "http") {
    const port = Number(process.env.PORT ?? 8080);
    const app = express();
    app.use(express.json());

    let transport: SSEServerTransport | null = null;

    app.get("/healthz", (_req, res) => {
      res.json({ ok: true, service: "pkb" });
    });

    app.get("/sse", async (_req, res) => {
      transport = new SSEServerTransport("/messages", res);
      await server.connect(transport);
    });

    app.post("/messages", async (req, res) => {
      if (!transport) {
        res.status(503).json({ error: "no active SSE session" });
        return;
      }
      await transport.handlePostMessage(req, res, req.body);
    });

    app.listen(port, () => {
      logger.info({ phase: "ready", transport: "sse", port });
    });
    return;
  }

  throw new Error(`unknown PKB_TRANSPORT: ${mode}`);
}

Five things to notice, top to bottom:

Step 2 — Local HTTP smoke test (no Docker yet)

Compile and run the HTTP transport directly — this isolates "does the TS code work" from "does the container work" so a later failure has a smaller search space:

npm run build
PKB_TRANSPORT=http PORT=8080 node dist/server.js &
sleep 1
curl -s localhost:8080/healthz
# {"ok":true,"service":"pkb"}

Then point Inspector at the HTTP endpoint:

npx @modelcontextprotocol/inspector http://localhost:8080/sse

In the Inspector UI, call echo with {"text":"http"}. You should see the exact same phase=start / phase=ok dur_ms=<tiny> text_len=4 log lines as the L12 stdio lab — the transport change is invisible to the tool layer. Kill the background server:

kill %1

If kill %1 errors, your shell didn't track the job — use pkill -f "node dist/server.js" instead, or just run the smoke test in a foreground terminal and Ctrl-C out.

Step 3 — Write the Dockerfile

Create Dockerfile in mcp-servers/sample-server/typescript/:

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

FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PKB_TRANSPORT=http
ENV PORT=8080
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 8080
CMD ["node", "dist/server.js"]

Walk through it layer by layer:

Step 4 — Write .dockerignore

Create .dockerignore next to the Dockerfile:

node_modules
dist
.git
.gitignore
*.log
RUNBOOK.md
.dockerignore
Dockerfile

Why this matters:

Step 5 — Build and run the image

Build it:

docker build -t pkb-ts:l13 .
docker images pkb-ts:l13   # expect ~80-120 MB

If the size is north of 200 MB you likely skipped .dockerignore — check the build output for "transferring context" and confirm it is single-digit MB, not hundreds.

Run it:

docker run --rm -p 8080:8080 -e PKB_TRANSPORT=http -d --name pkb-test pkb-ts:l13
sleep 2
curl -s localhost:8080/healthz
# {"ok":true,"service":"pkb"}

Inspector against the container:

npx @modelcontextprotocol/inspector http://localhost:8080/sse

Call echo again. In another terminal, look at the container logs:

docker logs pkb-test

Expected: a single startup line {"level":"info","service":"pkb","phase":"ready","transport":"sse","port":8080}, then the start/ok pair per Inspector call. Confirm USER node actually took effect:

docker exec pkb-test id
# uid=1000(node) gid=1000(node) groups=1000(node)

Stop the container:

docker stop pkb-test

Step 6 — Write docker-compose.yml and Caddyfile

Create docker-compose.yml next to the Dockerfile:

services:
  pkb:
    build: .
    environment:
      - PKB_TRANSPORT=http
      - PORT=8080
    expose:
      - "8080"
    restart: unless-stopped

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

volumes:
  caddy_data:
  caddy_config:

Two services. The pkb service uses expose: "8080" — not ports: — so it is reachable only on the internal compose network, never on the host. Only Caddy faces the internet on 80/443. The two named volumes (caddy_data for Let's Encrypt account + certs, caddy_config for Caddy's auto-saved config) survive docker compose down so a restart does not re-fetch a certificate (Let's Encrypt rate-limits domain issuance).

Create Caddyfile:

pkb.example.com {
    encode gzip
    reverse_proxy pkb:8080
    log {
        output stdout
        format json
    }
}

Six lines. The hostname (pkb:8080) on the reverse_proxy line is the compose service name — Docker's internal DNS resolves it to the pkb container. On first start, Caddy contacts Let's Encrypt's ACME endpoint, completes the HTTP-01 challenge on port 80, and writes the cert into /data (the named volume). Renewals happen automatically every 60 days.

Step 7a — Local-only path: bring up compose with tls internal

For everything except the final "real domain" production smoke, use tls internal. Edit Caddyfile to:

localhost {
    tls internal
    encode gzip
    reverse_proxy pkb:8080
}

Then bring up the stack:

docker compose up --build

In a second terminal, trust the local Caddy CA once so curl and your browser stop complaining about the self-signed cert:

docker compose exec caddy caddy trust   # one-time
curl -s https://localhost/healthz
# {"ok":true,"service":"pkb"}
npx @modelcontextprotocol/inspector https://localhost/sse

Inspector connects through Caddy → pkb → back through Caddy. Same echo round-trip you have done four times now, but now wrapped in HTTPS with a real-shaped TLS termination layer between client and server. Tear down:

docker compose down

If you want to also drop the trusted cert and the Let's Encrypt-style data directory:

docker compose down -v   # also removes the named volumes

Step 7b — Real-domain path: point DNS, bring up compose, watch ACME succeed

Only run this if you have a real domain pointed at a server you control. On that server, edit Caddyfile back to your real hostname:

pkb.example.com {
    encode gzip
    reverse_proxy pkb:8080
    log {
        output stdout
        format json
    }
}

Confirm DNS first — ACME will fail loudly if the A/AAAA record does not resolve to this machine's public IP:

dig +short pkb.example.com
# <your server's public IP>

Bring it up detached and watch Caddy obtain the cert:

docker compose up --build -d
docker compose logs -f caddy | grep -i "obtained"
# > caddy ... "obtained certificate for pkb.example.com"

Hit it from anywhere:

curl -s https://pkb.example.com/healthz
# {"ok":true,"service":"pkb"}
npx @modelcontextprotocol/inspector https://pkb.example.com/sse

Congratulations — the MCP server is now a real, internet-reachable HTTPS endpoint anyone with the URL can call. (That is the point of L14: anyone is too many; let's add bearer-token auth and per-tenant isolation next lesson.)

Step 8 — Audit checklist

Before calling L13 done, confirm

What you should leave with

Troubleshooting

Common stumbles