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_TRANSPORTenvironment 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