teachyou.ai academy
← All posts
MCPdockercontainersdevopsai-agents

Deploying MCP Servers with Docker

Pramod Dutta · Jun 27, 2026 · 12 min read

Running an MCP server docker setup solves a problem every team hits the moment they move past a single local integration: an MCP server that only runs via npx on your laptop is not something you can hand to a teammate, deploy to a shared box, or run in CI. Packaging the server into a Docker image gives you a fixed runtime, pinned dependencies, and a predictable way to start, stop, and network the process. This guide builds a real MCP server, containerizes it with a multi-stage Dockerfile, wires it into docker compose alongside a database, and connects it back to Claude Desktop and Claude Code.

We will use Node.js and the official @modelcontextprotocol/sdk package, but the same pattern (build a static runtime, drop it in a slim base image, expose a transport) applies just as well to a Python MCP server built on mcp or fastmcp.

What an MCP Server Actually Needs at Runtime

Before touching Docker, it helps to be clear on what an MCP server is at the process level. It is a long-running process that speaks the Model Context Protocol over one of two transports:

  • stdio: the server reads JSON-RPC messages from standard input and writes responses to standard output. This is how Claude Desktop and Claude Code launch local MCP servers by default, spawning the process directly.
  • HTTP (Streamable HTTP): the server listens on a TCP port and speaks the protocol over HTTP with server-sent events for streaming. This is what you want for anything that runs remotely, behind a load balancer, or is shared by multiple clients.

Docker changes the calculus here. A stdio server inside a container is awkward because the client needs to attach to the container's stdin/stdout, which usually means running the container in the foreground with docker run -i. An HTTP server inside a container is straightforward: you publish a port and any MCP-aware client can reach it over the network. If you are containerizing an MCP server specifically so it can be deployed somewhere other than your own machine, build it for HTTP transport.

Building a Minimal MCP Server

Start with a small server that exposes one tool: looking up the status of a fictional order. Create a project directory and initialize it.

mkdir mcp-order-server
cd mcp-order-server
npm init -y
npm install @modelcontextprotocol/sdk express zod
npm install -D typescript @types/node @types/express

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Now the server itself, in src/server.ts. It defines one tool (get_order_status), registers it against an McpServer instance, and mounts the Streamable HTTP transport on an Express app.

import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

const server = new McpServer({
  name: "order-status-server",
  version: "1.0.0"
});

server.registerTool(
  "get_order_status",
  {
    title: "Get Order Status",
    description: "Look up the current status of an order by ID",
    inputSchema: { orderId: z.string() }
  },
  async ({ orderId }) => {
    const status = await lookupOrder(orderId);
    return {
      content: [{ type: "text", text: `Order ${orderId} is ${status}` }]
    };
  }
);

async function lookupOrder(orderId: string): Promise<string> {
  const knownStatuses = ["processing", "shipped", "delivered"];
  const index = orderId.length % knownStatuses.length;
  return knownStatuses[index];
}

const app = express();
app.use(express.json());

app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined
  });
  res.on("close", () => transport.close());
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.get("/healthz", (_req, res) => res.status(200).send("ok"));

const port = process.env.PORT ? Number(process.env.PORT) : 3333;
app.listen(port, () => {
  console.log(`MCP server listening on port ${port}`);
});

Add a build script to package.json:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js"
  }
}

Run npm run build && npm start locally and confirm curl http://localhost:3333/healthz returns ok before moving to Docker. Debugging inside a container is slower than debugging on the host, so get the plain Node process working first.

Writing the Dockerfile

Use a multi-stage build so the final image only contains compiled JavaScript and production dependencies, not the TypeScript compiler or dev tooling.

# ---- build stage ----
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# ---- runtime stage ----
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist

RUN useradd --create-home --shell /bin/bash mcp \
    && chown -R mcp:mcp /app
USER mcp

EXPOSE 3333
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD node -e "require('http').get('http://localhost:3333/healthz', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"

CMD ["node", "dist/server.js"]

A few details worth calling out:

  • The build stage installs full devDependencies (needed for tsc) and is discarded. The runtime stage only runs npm ci --omit=dev, keeping the image lean.
  • Running as a non-root mcp user is a small but real security improvement. Most MCP servers do not need root inside the container.
  • HEALTHCHECK uses plain Node instead of curl since the slim base image does not ship curl by default and adding it just for a health probe is unnecessary weight.

Build and run it:

docker build -t order-status-mcp:latest .
docker run --rm -p 3333:3333 --name order-status-mcp order-status-mcp:latest

Confirm it responds:

curl -s http://localhost:3333/healthz

Running an MCP Server in Docker with Compose

Most real MCP servers need more than a bare Node process. They call a database, hold an API key, or need to sit on the same Docker network as another service. docker-compose.yml is the natural place to describe that.

services:
  order-status-mcp:
    build: .
    image: order-status-mcp:latest
    ports:
      - "3333:3333"
    environment:
      - PORT=3333
      - ORDERS_DB_URL=postgres://mcp:mcp@orders-db:5432/orders
      - LOG_LEVEL=info
    depends_on:
      orders-db:
        condition: service_healthy
    restart: unless-stopped

  orders-db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=mcp
      - POSTGRES_PASSWORD=mcp
      - POSTGRES_DB=orders
    volumes:
      - orders-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U mcp -d orders"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  orders-data:

Bring the stack up with docker compose up -d --build. The MCP server container can reach Postgres at the hostname orders-db, since compose puts every service on the same default network and resolves service names via DNS. This is the pattern to reach for whenever your MCP server needs a real backing store instead of the in-memory stub used in the earlier example.

Connecting Claude Desktop and Claude Code to a Dockerized Server

There are two shapes of connection, and it matters which one you use.

If the MCP server is running as an HTTP service (the setup above), point the client at the URL directly. In Claude Code, add a remote MCP server:

claude mcp add --transport http order-status http://localhost:3333/mcp

For Claude Desktop, the equivalent goes in claude_desktop_config.json under the mcpServers key:

{
  "mcpServers": {
    "order-status": {
      "url": "http://localhost:3333/mcp"
    }
  }
}

If instead you need the client to launch the container itself and talk over stdio (useful when you want Docker purely for dependency isolation, not for remote deployment), configure the client to run docker run as the command:

{
  "mcpServers": {
    "order-status": {
      "command": "docker",
      "args": [
        "run",
        "-i",
        "--rm",
        "order-status-mcp:latest"
      ]
    }
  }
}

For this to work, the server code inside the image needs to use the stdio transport (StdioServerTransport from the SDK) instead of the Express/HTTP setup shown earlier, and the -i flag is required so Docker keeps stdin open for the client to write to. The --rm flag cleans up the container once the client disconnects, which matters because Claude Desktop starts a fresh process per session and you do not want dozens of exited containers accumulating.

Handling Secrets and Environment Variables

Never bake API keys or database credentials into the image with ENV in the Dockerfile; anyone who can pull or inspect the image can read them back out with docker history or docker inspect. Pass secrets at run time instead.

For local development, an .env file referenced from compose works fine:

services:
  order-status-mcp:
    build: .
    env_file:
      - .env

Keep .env out of version control and commit an .env.example with the variable names but no values. For anything deployed beyond a laptop, prefer your platform's secret store (a cloud provider's secrets manager, Docker Swarm secrets, or Kubernetes Secret objects mounted as environment variables) over plain .env files, since those get baked into deployment artifacts more easily than people expect.

Keeping the Image Small

MCP servers are typically thin wrappers around a handful of tool calls, so image bloat is almost always self-inflicted. A few habits keep it in check:

  • Use a -slim or -alpine base image rather than the full node or python image. The difference is commonly several hundred megabytes.
  • Add a .dockerignore file so node_modules, .git, and build artifacts from the host never get copied into the build context:
node_modules
dist
.git
.env
*.log
  • Run npm ci --omit=dev (or pip install --no-cache-dir) in the runtime stage only, never in the build stage where dev dependencies are needed for compilation.
  • If the server has native dependencies that require build tools (node-gyp, gcc), install those build tools only in the build stage and copy just the compiled output forward, exactly like the multi-stage Dockerfile above.

Debugging a Containerized MCP Server

When a server behaves differently inside Docker than it did on the host, the fault is almost always one of these:

  • Working directory mismatch. COPY src ./src combined with a wrong WORKDIR can leave the compiled output somewhere the CMD does not expect. Run docker run --rm -it order-status-mcp:latest sh and ls dist to confirm the files actually landed where the start command looks for them.
  • Port not published. EXPOSE in the Dockerfile is documentation, not a network rule. Without -p 3333:3333 on docker run (or the equivalent ports: entry in compose), nothing on the host can reach the container's port.
  • Environment variables not passed through. Check with docker exec <container> env that the variables you expect are actually set inside the running container, not just in your shell.
  • Health check failing silently. docker ps shows a (unhealthy) status if the HEALTHCHECK command fails. docker inspect --format='{{json .State.Health}}' <container> prints the last few check attempts and their output, which is usually enough to spot a bad URL or missing binary.
  • stdio transport confusion. If you built the server for HTTP but configured the client to run it via docker run -i expecting stdio, the client will hang waiting for JSON-RPC frames on stdout that never come, because the process is busy running an Express server instead. Match the transport in the code to the transport the client config expects.

Tail logs with docker logs -f <container> while reproducing the issue; almost every MCP SDK throws a readable error to stderr when a tool handler fails or a schema validation rejects a request.

Production Considerations

Once the container runs correctly locally, a few additions matter before putting it in front of real traffic:

  • Restart policy. restart: unless-stopped in compose, or the equivalent restart policy on your orchestrator, keeps the server coming back after a crash or host reboot without requiring someone to notice and re-run docker run by hand.
  • Resource limits. Set memory and CPU limits (deploy.resources.limits in compose, or requests/limits in a Kubernetes pod spec) so a runaway tool call cannot take down the whole host.
  • Structured logging. Log JSON to stdout rather than freeform text, and let the container runtime or a log shipper handle aggregation. Avoid writing logs to a file inside the container, since that data disappears when the container is replaced.
  • Read-only root filesystem where possible. Adding read_only: true to a compose service (with an explicit writable tmpfs mount for anything that needs scratch space) reduces the blast radius if a tool handler has an injection bug.
  • Version pinning. Tag images with a commit SHA or semantic version rather than relying on latest, so a rollback is a one-line change to the deployed tag instead of a rebuild.

FAQ

Should an MCP server use stdio or HTTP transport inside Docker? Use HTTP (Streamable HTTP) whenever the container will run somewhere other than the exact machine the MCP client is on, including a remote server, a shared team environment, or CI. Reserve stdio-over-Docker for cases where you want the isolation and dependency pinning of a container but the client and container always run on the same host.

Does the MCP server need root inside the container? No. Create a dedicated non-root user in the Dockerfile (as shown with useradd above) and switch to it with USER before the CMD line. Most MCP servers only need to open an outbound HTTP connection or read a local file, neither of which requires root.

How do I pass an API key to a Dockerized MCP server without hardcoding it? Pass it as an environment variable at run time, either via -e KEY=value on docker run, an env_file entry in compose, or your platform's secret manager. Do not set it with ENV in the Dockerfile, since that value becomes part of the image itself and is visible to anyone with pull access.

Can one Docker image serve multiple MCP servers? Yes, if they share a runtime and dependencies, but it is usually cleaner to build one image per server and let each services: entry in compose run its own container. Sharing a single container across unrelated tools makes health checks, restarts, and resource limits harder to reason about independently.

Why does my MCP client hang when I configure it to launch a Docker container? This almost always means a transport mismatch: the client is waiting on stdio but the server inside the container is listening on an HTTP port instead, or the container was started without -i so stdin was never kept open. Check the transport the server code uses and match the client config to it exactly.

Do I need Kubernetes to run an MCP server in Docker? No. A single docker run command or a small docker-compose.yml is enough for most teams, including production use if the server sits behind a reverse proxy and has a restart policy. Kubernetes becomes worth the complexity once you need autoscaling, multiple replicas behind a load balancer, or centralized secret management across many services.