Deploying MCP Servers with Docker & Kubernetes

13 min read Module 3 of 10 Topic 8 of 30

What you'll learn

  • Write a production Dockerfile for a Streamable HTTP MCP server, including multi-stage builds and image hardening
  • Configure Kubernetes liveness and readiness probes appropriate for MCP's health semantics
  • Deploy an MCP server with a Deployment and Service so it is reachable cluster-wide
  • Implement graceful shutdown so in-flight tool calls are not dropped during a rolling deploy
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

A Streamable HTTP MCP server is, from an infrastructure standpoint, just an HTTP service, it containerizes and deploys the same way as any other backend API. The details that matter are keeping the image small and hardened, wiring health checks correctly, keeping the process stateless so it scales horizontally, and handling shutdown gracefully so rolling deploys do not silently drop in-flight work.

Dockerfile: Multi-Stage and Hardened

# Stage 1: build dependencies in a full image, discard build tools afterward
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/deps -r requirements.txt

# Stage 2: minimal runtime image, only the installed packages + app code
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /deps /usr/local/lib/python3.12/site-packages
COPY . .

# Non-root user: MCP servers handle enterprise data, run unprivileged
RUN useradd -m mcpuser && chown -R mcpuser /app
USER mcpuser

EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/health || exit 1

CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"]

The multi-stage split matters beyond image size: build tools (compilers, headers needed only to install certain Python packages) never end up in the runtime image at all, shrinking the attack surface, since a vulnerability in a build-only tool cannot be exploited in a container that never shipped it. Running as a non-root user is a similarly cheap, high-value hardening step, if a vulnerability in a dependency ever allows arbitrary code execution inside the container, a non-root process cannot escalate to modifying system files or escaping the container as easily as a root process could.

# server.py: expose /health separately from the MCP endpoint itself
from starlette.responses import JSONResponse
from starlette.routing import Route

async def health(request):
    return JSONResponse({"status": "ok"})

app = mcp.streamable_http_app()
app.router.routes.append(Route("/health", health))

Kubernetes Deployment with Correctly-Scoped Probes

apiVersion: apps/v1
kind: Deployment
metadata:
  name: enterprise-mcp-server
spec:
  replicas: 3
  strategy:
    rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }  # Never drop below full capacity during deploys
  selector:
    matchLabels: { app: enterprise-mcp-server }
  template:
    metadata:
      labels: { app: enterprise-mcp-server }
    spec:
      terminationGracePeriodSeconds: 45  # Give in-flight tool calls time to finish on SIGTERM
      containers:
        - name: mcp-server
          image: registry.internal/enterprise-mcp-server:1.4.0
          ports:
            - containerPort: 8080
          env:
            - name: DATABASE_URL
              valueFrom: { secretKeyRef: { name: mcp-db-secret, key: url } }
          resources:
            requests: { cpu: "250m", memory: "256Mi" }
            limits: { cpu: "1", memory: "512Mi" }
          readinessProbe:
            httpGet: { path: /health/ready, port: 8080 }  # Checks downstream deps
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet: { path: /health/live, port: 8080 }   # Process-only check
            initialDelaySeconds: 15
            periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
  name: enterprise-mcp-server
spec:
  selector: { app: enterprise-mcp-server }
  ports:
    - port: 80
      targetPort: 8080
# Two distinct health endpoints, not one, matching the two probes' different purposes
@app_router.get("/health/live")
async def liveness(request):
    """Process is running and the event loop is responsive. No downstream checks."""
    return JSONResponse({"status": "alive"})

@app_router.get("/health/ready")
async def readiness(request):
    """Can this pod actually serve tool calls right now? Checks real dependencies."""
    try:
        async with asyncio.timeout(2):
            await db_pool.execute("SELECT 1")
        return JSONResponse({"status": "ready"})
    except Exception:
        return JSONResponse({"status": "not_ready"}, status_code=503)
flowchart TB
    subgraph cluster["Kubernetes Cluster"]
        Svc["Service: enterprise-mcp-server\n(ClusterIP, load balances)"]
        Svc --> P1["Pod 1"]
        Svc --> P2["Pod 2"]
        Svc --> P3["Pod 3"]
        P1 --> DB[("Postgres\n(shared state)")]
        P2 --> DB
        P3 --> DB
    end
    Agent1["Agent Pod A"] --> Svc
    Agent2["Agent Pod B"] --> Svc

    style Svc fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style P1 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style P2 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style P3 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style DB fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style Agent1 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style Agent2 fill:#EEF0F7,stroke:#6366F1,color:#0F172A

Graceful Shutdown: Not Dropping In-Flight Work

When Kubernetes rolls out a new version, it sends SIGTERM to the old pod, waits up to terminationGracePeriodSeconds, and then force-kills anything still running. A server that ignores SIGTERM and simply gets killed mid-request drops whatever was in flight, an aborted tool call, or worse, a partially-completed write that never gets confirmed to the caller either way.

import signal, asyncio

shutting_down = False

def handle_sigterm(*args):
    global shutting_down
    shutting_down = True
    # Stop accepting new connections immediately; existing ASGI server
    # (uvicorn) drains in-flight requests within the grace period on its own.

signal.signal(signal.SIGTERM, handle_sigterm)

@app_router.get("/health/ready")
async def readiness(request):
    if shutting_down:
        # Tell Kubernetes to stop routing new traffic here the instant
        # SIGTERM arrives, even before the grace period elapses.
        return JSONResponse({"status": "shutting_down"}, status_code=503)
    ...

For the async job pattern from Lesson 27 specifically, this is why job state must live in external storage (Redis/Postgres) rather than only in the server process’s memory, a rolling deploy’s forced kill after the grace period will still terminate a background worker task mid-job if it runs longer than the grace period allows, and only a job whose progress was persisted externally can be picked back up by whichever replica handles the next poll, rather than silently losing all progress.

Keep the readiness probe honest, if it always returns 200 regardless of downstream health or shutdown state, Kubernetes will keep routing traffic to a pod that cannot actually serve tool calls, or worse, to one that is actively terminating. A slightly deeper readiness check, a lightweight database ping with a short timeout, plus the shutdown-state check above, catches both classes of failure before they reach an agent as a mysterious tool error.

The next lesson covers remote MCP servers reached over the public internet or a partner network, and the registry pattern used to discover them.

Knowledge Check

3 questions to test your understanding

1 Why should an MCP server's Kubernetes readiness probe check its downstream dependencies (e.g. database connectivity), while its liveness probe should not?

2 Why are MCP servers, as described in this lesson, well suited to horizontal scaling with multiple replicas behind a single Kubernetes Service?

3 During a rolling deploy, Kubernetes sends SIGTERM to a pod that is mid-way through executing three long-running tool calls (Lesson 27's async job pattern), then force-kills it after the default 30-second grace period. What should the server do on SIGTERM to avoid corrupting or silently dropping those in-flight operations?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan