25 min read
Intermediate
Deployment

Containerize MCP Servers with Docker

Containerize MCP servers with Docker for consistent, portable, and secure deployments

MCPgee Team

MCP Expert

Docker installed and runningA working MCP server (TypeScript or Python)Basic Docker and container knowledgeFamiliarity with Dockerfile syntax

Containerize MCP Servers with Docker

Introduction

Docker containers provide the ideal deployment model for MCP servers: consistent environments, easy scaling, security isolation, and portable deployments. This tutorial covers containerizing both TypeScript and Python MCP servers, from basic Dockerfiles to production-ready docker-compose configurations.

If you have not built an MCP server yet, start with our first MCP server tutorial or Python MCP server tutorial.

Why Docker for MCP Servers

  • Consistency: Same environment in development, staging, and production
  • Isolation: Each server runs in its own container with controlled access
  • Portability: Deploy to any platform that supports Docker
  • Security: Containers limit the attack surface (see security fundamentals)
  • Scalability: Easy horizontal scaling with orchestrators like Kubernetes

Containerizing a TypeScript MCP Server

Basic Dockerfile

dockerfile
FROM node:20-alpine

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install production dependencies
RUN npm ci --production

# Copy source files
COPY src/ ./src/
COPY tsconfig.json ./

# Build TypeScript
RUN npm run build

# Run the server
CMD ["node", "dist/server.js"]

Multi-Stage Build (Recommended)

Multi-stage builds produce smaller images by separating build and runtime:

dockerfile
# Build stage
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src/ ./src/
COPY tsconfig.json ./
RUN npm run build

# Production stage
FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --production && npm cache clean --force

COPY --from=builder /app/dist ./dist

# Run as non-root user
RUN addgroup -g 1001 -S mcpuser && \
    adduser -S mcpuser -u 1001
USER mcpuser

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

Build and Run

bash
docker build -t my-mcp-server:latest .

# For stdio transport (interactive mode)
docker run -i --rm my-mcp-server:latest

# For Streamable HTTP transport
docker run -d -p 3000:3000 --name mcp-server my-mcp-server:latest

Containerizing a Python MCP Server

Basic Dockerfile

dockerfile
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY server.py ./

# Run as non-root user
RUN useradd -m -u 1001 mcpuser
USER mcpuser

CMD ["python", "server.py"]

With uv Package Manager

dockerfile
FROM python:3.12-slim

WORKDIR /app

# Install uv
RUN pip install uv

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

COPY src/ ./src/

RUN useradd -m -u 1001 mcpuser
USER mcpuser

CMD ["uv", "run", "python", "src/server.py"]

Transport Configuration

stdio Transport in Docker

For stdio-based MCP servers, the container must run in interactive mode:

bash
docker run -i --rm my-mcp-server:latest

Configure in Claude Desktop:

json
{
  "mcpServers": {
    "docker-server": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "my-mcp-server:latest"]
    }
  }
}

For VS Code:

json
{
  "servers": {
    "docker-server": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "my-mcp-server:latest"]
    }
  }
}

Streamable HTTP Transport in Docker

For remote access, use Streamable HTTP and expose the port:

python
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("docker-server")

@mcp.tool()
def hello(name: str) -> str:
    """Greet someone.

    Args:
        name: Name to greet
    """
    return f"Hello from Docker, {name}!"

if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="0.0.0.0", port=3000)
bash
docker run -d -p 3000:3000 my-mcp-server:latest

Docker Compose for Multiple Servers

For projects that need multiple MCP servers running together:

yaml
version: '3.8'

services:
  file-server:
    build:
      context: ./servers/file-server
    ports:
      - "3001:3000"
    volumes:
      - ./data:/app/data:ro
    environment:
      - DATA_DIR=/app/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  db-server:
    build:
      context: ./servers/db-server
    ports:
      - "3002:3000"
    environment:
      - DATABASE_URL=postgresql://postgres:password@db:5432/mydb
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

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

volumes:
  pgdata:

Start all services:

bash
docker compose up -d

Security Best Practices

Non-Root User

Always run containers as a non-root user:

dockerfile
RUN addgroup -g 1001 -S mcpuser && \
    adduser -S mcpuser -u 1001
USER mcpuser

Read-Only Filesystem

Mount volumes as read-only when possible:

bash
docker run -v /data:/app/data:ro my-mcp-server:latest

Network Isolation

Use Docker networks to limit communication:

yaml
services:
  mcp-server:
    networks:
      - mcp-net
    # No access to the internet or other networks

networks:
  mcp-net:
    internal: true

Secrets Management

Never bake secrets into images. Use environment variables or Docker secrets:

bash
# Environment variables
docker run -e API_KEY=secret my-mcp-server:latest

# Docker secrets (Swarm mode)
echo "my-secret" | docker secret create mcp_api_key -

For comprehensive security guidance, see our security fundamentals tutorial and authentication tutorial.

Volume Mounts for Data

Sharing Data with MCP Servers

bash
# Mount a local directory for the file server to access
docker run -v /home/user/documents:/app/data:ro my-file-server:latest

# Mount for write access (be careful with permissions)
docker run -v /home/user/workspace:/app/workspace my-file-server:latest

Persistent Storage

yaml
services:
  mcp-server:
    volumes:
      - mcp-cache:/app/cache      # Persistent cache
      - ./config:/app/config:ro   # Configuration (read-only)

volumes:
  mcp-cache:

Health Checks

Add health check endpoints to your MCP servers:

python
from mcp.server.fastmcp import FastMCP
from http.server import HTTPServer, BaseHTTPRequestHandler
import threading

mcp = FastMCP("healthy-server")

class HealthHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok")
    def log_message(self, format, *args):
        pass

def start_health_server():
    server = HTTPServer(("0.0.0.0", 8080), HealthHandler)
    server.serve_forever()

threading.Thread(target=start_health_server, daemon=True).start()

@mcp.tool()
def hello(name: str) -> str:
    """Say hello.

    Args:
        name: Name to greet
    """
    return f"Hello, {name}!"

if __name__ == "__main__":
    mcp.run()
dockerfile
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD curl -f http://localhost:8080/ || exit 1

Optimizing Docker Images

.dockerignore

Create a .dockerignore to keep images small:

plaintext
node_modules
.git
.env
*.md
tests/
.vscode/
__pycache__/
.pytest_cache/

Layer Caching

Order Dockerfile instructions from least to most frequently changing:

  1. Base image and system packages
  2. Package manager files (package.json, requirements.txt)
  3. Dependency installation
  4. Source code copy
  5. Build step

This maximizes Docker layer cache hits during rebuilds.

CI/CD Integration

GitHub Actions Example

yaml
name: Build and Push MCP Server

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}/mcp-server:latest

Next Steps

Once your MCP server is containerized, consider:

For more Docker-specific MCP server examples, explore our Docker servers directory.

Conclusion

Docker provides a robust foundation for deploying MCP servers. Multi-stage builds keep images small, security best practices protect your infrastructure, and docker-compose simplifies multi-server setups. Combined with health checks and CI/CD pipelines, you can deploy and maintain MCP servers with confidence.

Code Examples

Multi-Stage TypeScript Dockerfiledockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src/ ./src/
COPY tsconfig.json ./
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production && npm cache clean --force
COPY --from=builder /app/dist ./dist
RUN addgroup -g 1001 -S mcpuser && adduser -S mcpuser -u 1001
USER mcpuser
CMD ["node", "dist/server.js"]
Python MCP Server Dockerfiledockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py ./
RUN useradd -m -u 1001 mcpuser
USER mcpuser
CMD ["python", "server.py"]
Docker Compose Multi-Server Setupyaml
version: '3.8'
services:
  file-server:
    build: ./servers/file-server
    ports:
      - "3001:3000"
    volumes:
      - ./data:/app/data:ro
    restart: unless-stopped

  db-server:
    build: ./servers/db-server
    ports:
      - "3002:3000"
    environment:
      - DATABASE_URL=postgresql://postgres:pass@db:5432/mydb
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=mydb
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Key Takeaways

  • Multi-stage Docker builds produce small, secure MCP server images
  • Use stdio transport with docker run -i for local clients like Claude Desktop
  • Streamable HTTP transport enables remote access to containerized MCP servers
  • Always run containers as non-root users and use read-only mounts where possible
  • Docker Compose simplifies deploying multiple MCP servers with shared dependencies

Troubleshooting

Container exits immediately when using stdio transport

Make sure you run the container with the -i flag (interactive mode). Without it, the container has no stdin and the stdio transport will close immediately. Use: docker run -i --rm my-mcp-server:latest

Cannot connect to Streamable HTTP server in container

Ensure the server binds to 0.0.0.0, not 127.0.0.1, inside the container. Map the port with -p: docker run -p 3000:3000 my-mcp-server. Check that no firewall rules are blocking the port.

Permission denied errors when accessing mounted volumes

The non-root user in the container needs read permission on mounted files. Either adjust file permissions on the host, or use the same UID in the container as the file owner on the host.

Next Steps

  • Scale your deployment with Kubernetes orchestration
  • Set up CI/CD pipelines to automate container builds
  • Add authentication to your containerized MCP servers
  • Explore serverless deployment with AWS Lambda

Was this helpful?

Share tutorial:

Stay Updated with MCP Insights

Join 5,000+ developers and get weekly insights on MCP development, new server releases, and implementation strategies delivered to your inbox.

We respect your privacy. Unsubscribe at any time.

MCPgee Team

We write in-depth guides, tutorials, and reviews to help developers get the most out of the Model Context Protocol ecosystem.

Frequently Asked Questions

Explore MCP Servers

Browse our directory of 33,000+ MCP servers. Find the perfect tools for your AI-powered workflows.