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
- L12 lab complete. PKB-TS responds to
echoover stdio (Inspector smoke test passes againstnode dist/server.js). The four-file layout (server.ts,tools.ts,schemas.ts,logger.ts) is insrc/;npm run buildexits clean. - Docker daemon running.
docker --versionreports 24+ or 25+, anddocker run --rm hello-worldfinishes successfully. On macOS / Windows that means Docker Desktop is open; on Linux that meanssystemctl status dockershows active. - Working directory. Every command runs from
mcp-servers/sample-server/typescript/unless noted. Open a shell there now. - Optional — for the real-TLS path: a domain you
control, a DNS A/AAAA record pointed at a server you own, and that server's
ports 80 and 443 reachable from the public internet (Let's Encrypt's
HTTP-01 ACME challenge uses port 80). For local-only work, skip the domain;
the lab's local path uses
tls internal.
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:
- The default is still
stdio. Unset env var = L12 behaviour. Your L12 lab still works without any flag changes. app.use(express.json())is mandatory. Without it,POST /messagesseesreq.body === undefinedand the SSE stream hangs silently — a common first-time bug./healthzreturns a flat{ok, service}object, nothing else. Sub-1 ms, dependency-free. Docker, Caddy, and any future Kubernetes probe all read this same route.- The single-session pattern (
let transport: ... | null). One Inspector at a time is fine for L13. A secondGET /sseoverwrites the first — that is by design here. L14's tenant-keyed map fixes it once auth lands. logger.info({phase:"ready", transport:"sse", port})fires inside thelistencallback, not before. Same rule as L12 stdio: log "ready" only after the listener is actually bound.
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:
FROM node:20 AS build— the "full toolchain" stage. Debian-based, ~1 GB on disk, but it has everythingtscand any native-module build step might need. Everything here is thrown away at the end of the build.COPY package.json package-lock.json* ./thenRUN npm ci. The two-line dance is intentional — by copying justpackage.json/package-lock.jsonbefore the source, Docker caches the install layer. Subsequent rebuilds where onlysrc/changed reuse the install layer for free.FROM node:20-alpine AS runtime— the slim stage. Alpine base is ~30 MB; final image with our deps lands at ~80–100 MB.ENV PKB_TRANSPORT=httpandENV PORT=8080. Defaults baked into the image sodocker run pkb-ts:l13works with zero flags. Compose can override either at runtime.npm ci --omit=devin the runtime stage excludestsx,pino-pretty,@types/*, and any test-only deps. Smaller attack surface, smaller layer, faster pulls.COPY --from=build /app/dist ./distcopies only the compiled JS from the build stage. Nosrc/, notsconfig.json, no source maps unless you opt in.USER nodedrops root. The officialnodeimages bundle UID 1000 as a non-rootnodeuser. This is the single most important runtime-security default.
Step 4 — Write .dockerignore
Create .dockerignore next to the Dockerfile:
node_modules
dist
.git
.gitignore
*.log
RUNBOOK.md
.dockerignore
Dockerfile
Why this matters:
node_modulesin particular must be ignored. Without it, Docker copies your host-installed tree (often hundreds of MB and possibly built for a different platform) into the build context. Everydocker builduploads it to the daemon; you get slow builds and the risk that the host's binary modules don't run in the alpine runtime.distis ignored too. The build stage regenerates it fromsrc/; the host'sdist/might be stale or from a different commit..gitis ignored because committing history into a container image is a leak vector — old commits might contain secrets the current tree no longer does.*.logandRUNBOOK.mdnever belong in the image; the runbook (added in L15) is reference doc, not runtime data.
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
- Container runs as
node, not root.docker exec pkb-test idshowsuid=1000(node). If it showsuid=0(root), theUSER nodeline was accidentally removed or placed before the wrongFROM. - Image size is < 150 MB.
docker images pkb-ts:l13. If much larger, recheck.dockerignoreand confirm the runtime stage isnode:20-alpine, notnode:20. - Image contains no source, no
node_modulesduplicates, no.git.docker run --rm --entrypoint sh pkb-ts:l13 -c "ls -la"— onlydist/,package.json,package-lock.json, andnode_modules(production-only) should appear. Nosrc/, no.git/, noRUNBOOK.md. /healthzreturns in < 50 ms.time curl -s localhost:8080/healthz. If it takes longer, you have inadvertently made it depend on something heavy — revert to the flat{ok, service}response.- The
Authorizationheader is not logged.curl -H "Authorization: Bearer x" localhost:8080/healthz, thendocker logs pkb-test | grep -i bearer— expect no output. L13 does not yet read the header, but you want to confirm Express's default access logging is not echoing it either. (L14 reads the header; L15's audit log records the fact of auth, never the token itself.)
What you should leave with
- A PKB-TS Docker image that runs as a non-root user, weighs
~80–100 MB, and answers
echoover HTTP+SSE on port 8080. - A working compose topology with Caddy in front and the
pkb service reachable only via the reverse proxy. Local
(
tls internal) or real-domain (Let's Encrypt) — both code paths exercised. - A
/healthzroute wired for any future load balancer or orchestrator probe, kept deliberately trivial. - The transport-switch pattern in your fingertips.
PKB_TRANSPORT=stdiofor the L12 lab,PKB_TRANSPORT=httpfor the L13 container, same tool code in both branches. The SDK's transport abstraction earns its keep here. - If you ran Step 7b: a real HTTPS MCP endpoint that Inspector (and, in L14, real authenticated clients) can connect to from the internet.
Troubleshooting
Common stumbles
- "
SSEServerTransport is not a constructor". Wrong import path. Use@modelcontextprotocol/sdk/server/sse.js, not.../sseand not.../sse.ts. The.jsextension on local and on package subpath imports is non-negotiable under ESM. - "Inspector connects, then immediately disconnects".
You forgot
await server.connect(transport)inside theGET /ssehandler. The SSE response is open but no protocol handler is attached, so the SDK closes it on the first message. POST /messageshandler seesundefinedbody.express.json()middleware is missing. Addapp.use(express.json())before any route declaration.- Caddy logs
"
unable to get certificate". Either DNS does not resolve to your server's public IP, or port 80 is blocked upstream (cloud firewall, ISP, container host). ACME HTTP-01 needs port 80 reachable from the outside even though the app traffic flows on 443.dig +short your.hostandcurl http://your.host/from another machine before debugging compose. compose uphangs onnpm ci. Corrupt or out-of-syncpackage-lock.json. Delete it locally, runnpm installonce to regenerate, commit the new lockfile, rebuild./healthzreturns 503 from Caddy. The pkb container isn't ready yet when Caddy starts probing. Either restart compose so the second start finds pkb already up, or do the homework Task 1 (add a Docker healthcheck on pkb plusdepends_on: condition: service_healthyon caddy).- Container size is > 250 MB. You forgot
.dockerignore(the hostnode_modulesgot copied in), or your runtime stage isnode:20instead ofnode:20-alpine, or you skipped--omit=devon the runtimenpm ci. All three together explain a 10x size blow-up. - Both host port and Caddy expose the server.
You wrote
ports: ["8080:8080"]on thepkbservice. Switch toexpose: ["8080"]so the container is reachable only inside the compose network — otherwise an attacker can skip TLS and hit pkb directly onhttp://host:8080. - The literal request body or
Authorizationheader appears in a log line. Stop. The L12 log discipline (lengths and types, never values) carries over to L13 as-is — the only thing the HTTP transport adds is the{phase:"ready", transport:"sse", port}startup line. No request bodies, no headers, no client IPs. If you see any of those, check whether Express'smorganor a similar middleware sneaked in; remove it.