advanced
ai-engineering

Production Deployment

Deploy MCP servers with Docker, health checks, and scaling strategies

mcp fundamentals
28 min read

Shipping MCP to Production

A local MCP server that talks to Claude Desktop over stdio is one thing; a server that dozens of clients hit over the network, all day, is another. Production deployment is where you decide on a transport, containerize the process, expose health probes so your orchestrator can restart it when it wedges, inject secrets safely, and put a TLS-terminating proxy in front of the streaming endpoint. Get these right and your MCP server behaves like any other well-run microservice — observable, restartable, and horizontally scalable.

Learning Objectives
  • Choose between stdio and Streamable HTTP transports for production
  • Build a lean multi-stage Docker image and run it as a non-root user
  • Expose liveness and readiness endpoints and wire them to Docker and Kubernetes probes
  • Inject configuration and secrets through environment variables
  • Run behind an Nginx reverse proxy with TLS and correct streaming settings
  • Scale sessions horizontally without dropping stateful connections

Pick the Right Transport

The transport decides everything downstream — how you scale, whether you need a proxy, and how clients authenticate. Local integrations keep using stdio; remote deployments should use the Streamable HTTP transport (the successor to the deprecated HTTP+SSE transport from the 2024 spec).

A single HTTP endpoint (typically /mcp) that handles POST for client-to-server messages and streams server-to-client events over the same connection. This is the transport to deploy remotely: it works behind proxies, supports auth headers, and can scale to many clients.

Here is a minimal but production-shaped Streamable HTTP server using Express and the official SDK. Note the session map — Streamable HTTP is stateful by default, and each session gets its own transport.

ts
import express from "express" import { randomUUID } from "node:crypto" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js" const app = express() app.use(express.json()) const transports: Record<string, StreamableHTTPServerTransport> = {} app.post("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string | undefined let transport: StreamableHTTPServerTransport if (sessionId && transports[sessionId]) { transport = transports[sessionId] } else if (!sessionId && isInitializeRequest(req.body)) { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports[id] = transport }, }) transport.onclose = () => { if (transport.sessionId) delete transports[transport.sessionId] } const server = new McpServer({ name: "weather-mcp", version: "1.0.0" }) // ...register tools/resources here... await server.connect(transport) } else { res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request: No valid session ID" }, id: null, }) return } await transport.handleRequest(req, res, req.body) })

Build a Production Docker Image

A multi-stage build keeps your final image small: compile TypeScript in a builder stage, then copy only the compiled dist/ and production dependencies into a slim runtime stage. Always run as a non-root user.

dockerfile
# syntax=docker/dockerfile:1 FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist USER node EXPOSE 3000 CMD ["node", "dist/index.js"]

Add Health and Readiness Endpoints

Orchestrators need two distinct signals. Liveness answers "is the process alive?" — if it fails, the container is restarted. Readiness answers "can it serve traffic right now?" — if it fails, traffic is withheld but the container is left running (useful during warm-up or a downstream outage). Serve both outside the MCP auth layer so probes never carry credentials.

ts
let ready = false app.get("/healthz", (_req, res) => { res.status(200).json({ status: "ok" }) }) app.get("/readyz", (_req, res) => { res.status(ready ? 200 : 503).json({ ready }) }) app.listen(Number(process.env.PORT ?? 3000), () => { ready = true // flip to true once downstreams (DB, upstream APIs) are reachable })

Wire the same endpoint into a Docker HEALTHCHECK and, in Kubernetes, into probes:

dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD node -e "fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

Configuration and Secrets

Never bake API keys into the image. Read everything from the environment, validate at startup, and fail fast if a required value is missing — a container that crashes immediately is far easier to debug than one that runs with a silently-undefined secret.

ts
const config = { port: Number(process.env.PORT ?? 3000), apiKey: process.env.MCP_API_KEY, upstreamUrl: process.env.WEATHER_API_URL ?? "https://api.open-meteo.com", logLevel: process.env.LOG_LEVEL ?? "info", } if (!config.apiKey) { throw new Error("MCP_API_KEY is required but was not provided") }

A docker-compose.yml injects those values and reads secrets from the host environment (or an .env file that stays out of version control):

yaml
services: mcp: build: . ports: - "3000:3000" environment: NODE_ENV: production MCP_API_KEY: ${MCP_API_KEY} LOG_LEVEL: info healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s timeout: 3s retries: 3 restart: unless-stopped

Reverse Proxy and TLS

Terminate TLS at a reverse proxy and forward plain HTTP to the MCP process on localhost. The one non-obvious detail: the Streamable HTTP transport keeps a long-lived streaming response open, so you must disable response buffering or clients will see events arrive in delayed bursts instead of in real time.

nginx
server { listen 443 ssl; server_name mcp.example.com; ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem; location /mcp { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Streaming: flush events immediately instead of buffering proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; } }

Scaling Strategies

Because Streamable HTTP sessions are held in server memory, a request carrying an Mcp-Session-Id must land on the instance that created it. You have two production-grade options:

Session affinity vs. stateless mode

Sticky sessions: configure your load balancer to route by the Mcp-Session-Id header so follow-up requests reach the right replica. Simple, but a lost pod drops its sessions.



Stateless mode: construct the transport with sessionIdGenerator: undefined. Every request is self-contained, so any replica can serve any request and you scale like a normal stateless service — at the cost of per-connection server state.

ts
// Stateless: no session map, safe for round-robin load balancing const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }) const server = new McpServer({ name: "weather-mcp", version: "1.0.0" }) await server.connect(transport) await transport.handleRequest(req, res, req.body)

Common Pitfalls

Best Practices

Next Steps

Continue Learning
Scopes and validation.

Next Lesson

Security & Sandboxing