Building an MCP Server - Part 4: Docker & Traefik Deployment

Our MCP server works great locally via stdio. Now let's make it production-ready with Docker and deploy it with automatic SSL certificates via Traefik. We'll support both modes: local (stdio) for Claude Code, and remote (SSE) for network access.

By the end, you'll have a containerized MCP server accessible at https://mcp-plane.yourdomain.com with Let's Encrypt SSL.

Why Deploy an MCP Server?

Local stdio mode is perfect for Claude Code on your machine. But what if you want:

  • Remote access from multiple machines
  • Team sharing (multiple users, one server)
  • Cloud deployment (run on VPS, not localhost)
  • Always-on availability (doesn't require your laptop)
  • Centralized logs and monitoring

That's where SSE (HTTP/Server-Sent Events) mode comes in.

Transport Modes Comparison

Feature stdio Mode SSE Mode
Access Local only Network accessible
Use Case Claude Code integration Remote clients, APIs
Authentication None (local trust) API keys, IP whitelist
Setup Simple Docker + reverse proxy
SSL Not needed Required for production
Performance Fast (local process) Network latency

Best practice: Use stdio for local development, SSE for production deployment.

Dual-Mode Server Implementation

Update src/server.py to support both modes:

"""
Plane MCP Server - Dual Transport Mode
"""
import os
from mcp.server import FastMCP
from config import Config
from plane_client import PlaneClient
from tools.plane_tools import PlaneTools
from tools.git_tools import GitTools
from tools.github_tools import GitHubTools

# Initialize configuration
config = Config()
if not config.validate():
    raise ValueError("Missing required configuration. Check .env file.")

# Initialize MCP server
mcp = FastMCP("Plane MCP Server")

# Initialize clients
plane_client = PlaneClient(
    api_url=config.plane_api_url,
    api_token=config.plane_api_token,
    workspace_slug=config.plane_workspace_slug
)

# Initialize tool providers
plane_tools = PlaneTools(plane_client)
git_tools = GitTools()
github_tools = GitHubTools()

# ... Register all 35 tools (see Parts 2-3)

# Main entry point
if __name__ == "__main__":
    transport = config.mcp_transport

    if transport == "sse":
        # HTTP/SSE mode for remote access
        print(f"Starting MCP server in SSE mode on {config.mcp_host}:{config.mcp_port}")
        mcp.run(
            transport="sse",
            host=config.mcp_host,
            port=config.mcp_port
        )
    else:
        # stdio mode for local Claude Code
        print("Starting MCP server in stdio mode")
        mcp.run()

What changed:

  • Check MCP_TRANSPORT environment variable
  • Run stdio by default (backward compatible)
  • Run SSE mode with host/port when configured

Docker Configuration

Dockerfile

Create Dockerfile:

FROM python:3.11-slim

# Set working directory
WORKDIR /app

# Install system dependencies
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    git \
    && rm -rf /var/lib/apt/lists/*

# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY src/ ./src/

# Create logs directory
RUN mkdir -p /app/logs

# Environment defaults (overridden by docker-compose)
ENV MCP_TRANSPORT=sse \
    MCP_HOST=0.0.0.0 \
    MCP_PORT=8000 \
    PYTHONUNBUFFERED=1

# Expose HTTP port
EXPOSE 8000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD python -c "import requests; requests.get('http://localhost:8000/health')" || exit 1

# Run server
CMD ["python", "-m", "src.server"]

Key features:

  • Slim Python 3.11 base image
  • Git installed (for git tools)
  • Unbuffered Python output (better logs)
  • Health check endpoint
  • Runs as MCP server

Docker Compose with Traefik

Create docker-compose.yml:

services:
  mcp-plane:
    container_name: mcp-plane
    build:
      context: .
      dockerfile: Dockerfile
    restart: unless-stopped

    env_file:
      - .env

    environment:
      - MCP_TRANSPORT=sse
      - MCP_HOST=0.0.0.0
      - MCP_PORT=8000

    networks:
      - traefik-network  # External - shared with Traefik
      - mcp-internal     # Internal - isolated

    volumes:
      - ./logs:/app/logs
      # For development: mount source code
      # - ./src:/app/src

    labels:
      # Enable Traefik
      - "traefik.enable=true"
      - "traefik.docker.network=traefik-network"

      # HTTP Router
      - "traefik.http.routers.mcp-plane.rule=Host(`mcp-plane.yourdomain.com`)"
      - "traefik.http.routers.mcp-plane.entrypoints=websecure"
      - "traefik.http.routers.mcp-plane.tls.certresolver=letsencrypt"
      - "traefik.http.services.mcp-plane.loadbalancer.server.port=8000"

      # Rate limiting middleware
      - "traefik.http.middlewares.mcp-ratelimit.ratelimit.average=100"
      - "traefik.http.middlewares.mcp-ratelimit.ratelimit.burst=200"

      # Apply middlewares
      - "traefik.http.routers.mcp-plane.middlewares=mcp-ratelimit"

networks:
  traefik-network:
    external: true  # Assumes Traefik network exists
  mcp-internal:
    driver: bridge

Traefik features:

  • Automatic SSL: Let's Encrypt certificates
  • Rate limiting: 100 req/sec average, 200 burst
  • Host routing: Route by domain name
  • Load balancer: Port mapping to container
  • External network: Shared with other services

Prerequisites:

You need Traefik running. If you don't have it, check out Part 1 of the Traefik tutorial series.

Quick Traefik setup:

# traefik/docker-compose.yml
services:
  traefik:
    image: traefik:v2.10
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    command:
      - "--providers.docker=true"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.letsencrypt.acme.email=you@example.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
    networks:
      - traefik-network

networks:
  traefik-network:
    name: traefik-network

Environment Configuration

Update .env for production:

# Plane API Configuration
PLANE_API_URL=https://api.plane.so
PLANE_API_TOKEN=your_plane_api_token_here
PLANE_WORKSPACE_SLUG=your-workspace

# MCP Server Configuration
MCP_TRANSPORT=sse    # Use sse for Docker deployment
MCP_HOST=0.0.0.0     # Listen on all interfaces
MCP_PORT=8000        # Internal container port

# GitHub Integration (optional)
GITHUB_TOKEN=your_github_token_here

# Logging
LOG_LEVEL=INFO
LOG_DIR=/app/logs

Important: Never commit .env to Git. Add to .gitignore:

.env
logs/
*.pyc
__pycache__/

Building and Deploying

Step 1: Build Container

# Build image
docker compose build

# Check image
docker images | grep mcp-plane

Step 2: Deploy

# Start in background
docker compose up -d

# Check status
docker compose ps

# View logs
docker logs -f mcp-plane

Expected output:

Starting MCP server in SSE mode on 0.0.0.0:8000
Server listening on http://0.0.0.0:8000

Step 3: Verify Deployment

# Health check (internal)
docker exec mcp-plane curl http://localhost:8000/health

# Health check (external via Traefik)
curl https://mcp-plane.yourdomain.com/health

# List tools
curl https://mcp-plane.yourdomain.com/tools

Testing Both Modes

Local stdio Mode

For Claude Code (local development):

# Run locally
export MCP_TRANSPORT=stdio
python -m src.server

Configure ~/.claude.json:

{
  "mcpServers": {
    "plane-local": {
      "command": "python3",
      "args": ["-m", "src.server"],
      "cwd": "/path/to/plane-mcp-server",
      "env": {
        "MCP_TRANSPORT": "stdio",
        "PLANE_API_URL": "https://api.plane.so",
        "PLANE_API_TOKEN": "your_token",
        "PLANE_WORKSPACE_SLUG": "your-workspace"
      }
    }
  }
}

Remote SSE Mode

For remote access (production):

# Call via HTTP
curl -X POST https://mcp-plane.yourdomain.com/ \
  -H "Content-Type: application/json" \
  -d '{
    "tool": "plane_list_projects",
    "arguments": {}
  }'

Response:

{
  "count": 5,
  "projects": [
    {
      "id": "abc123",
      "name": "Portfolio",
      "identifier": "PORT"
    },
    ...
  ]
}

Architecture Diagram

Local Development (stdio):
┌──────────────┐
│ Claude Code  │
│              │
│ python3 -m   │
│ src.server   │
└──────┬───────┘
       │ stdio
       ↓
┌──────────────┐
│ MCP Server   │
│ (stdio mode) │
└──────────────┘

Production Deployment (SSE):
┌──────────────┐
│   Internet   │
└──────┬───────┘
       │ HTTPS
       ↓
┌──────────────────┐
│    Traefik       │
│  - SSL Certs     │
│  - Rate Limit    │
│  - Routing       │
└──────┬───────────┘
       │ HTTP
       ↓
┌──────────────────┐
│  Docker Network  │
│  mcp-plane:8000  │
└──────┬───────────┘
       │
       ↓
┌──────────────────┐
│   MCP Server     │
│   (SSE mode)     │
└──────┬───────────┘
       │ HTTP
       ↓
┌──────────────────┐
│   Plane API      │
│   GitHub API     │
└──────────────────┘

Health Monitoring

The server includes a health check endpoint:

# Add to src/server.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
def health_check():
    """Health check endpoint for monitoring"""
    return {
        "status": "healthy",
        "transport": "sse",
        "tools_count": 35
    }

@app.get("/tools")
def list_tools():
    """List all available MCP tools"""
    return {
        "tools": [
            {"name": "plane_list_projects", "category": "Plane"},
            {"name": "plane_create_issue", "category": "Plane"},
            # ... all 35 tools
        ]
    }

Logs and Debugging

View Logs

# Real-time logs
docker logs -f mcp-plane

# Last 100 lines
docker logs --tail 100 mcp-plane

# Search logs
docker logs mcp-plane 2>&1 | grep ERROR

Log Volumes

Logs are persisted in ./logs directory:

logs/
├── mcp-server.log        # Application logs
├── access.log            # HTTP requests
└── error.log             # Errors only

Debug Mode

Enable debug logging:

# In .env
LOG_LEVEL=DEBUG

# Restart
docker compose restart

Updating the Server

Code Changes

# Pull latest code
git pull origin main

# Rebuild and restart
docker compose down
docker compose build
docker compose up -d

# Verify
docker logs -f mcp-plane

Environment Changes

# Edit .env file
vim .env

# Restart (no rebuild needed)
docker compose down
docker compose up -d

What We've Built

✅ Dual-mode MCP server (stdio + SSE) ✅ Docker containerization with health checks ✅ Traefik integration with automatic SSL ✅ Production-ready deployment ✅ Log persistence and monitoring ✅ Both local and remote access patterns

Try this: Deploy your server and access it from a different machine!

Next: Production Hardening

In Part 5, we'll add security and production features:

  • API key authentication
  • IP whitelisting
  • Rate limiting strategies
  • Error handling best practices
  • Testing and validation
  • Monitoring and alerting

→ Continue to Part 5: Production & Security

Resources