Skip to content

TLS/SSL Configuration Guide

This guide explains how to configure TLS/SSL encryption for the MCP Gateway and nginx reverse proxy in Docker Compose deployments.

⚠️ Important: TLS is OPTIONAL and DISABLED by default. The default deployment uses HTTP-only for simplicity. Enable TLS only when needed for production or testing secure connections.


Quick Start: Zero-Config TLS

The fastest way to enable HTTPS is using the --profile tls Docker Compose profile. This automatically generates self-signed certificates and configures nginx with TLS—no manual configuration required.

One Command

make compose-tls

This starts the full stack with:

  • HTTP: http://localhost:8080
  • HTTPS: https://localhost:8443
  • Admin UI: https://localhost:8443/admin

What Happens

  1. The cert_init container checks if certificates exist in ./certs/
  2. If missing, it auto-generates a self-signed certificate valid for 365 days
  3. The nginx_tls service starts with TLS enabled on port 8443
  4. Both HTTP (8080) and HTTPS (8443) are available

Bringing Your Own Certificates

You can use your own CA-signed certificates or custom certificates instead of auto-generated self-signed ones. The TLS profile supports both unencrypted and passphrase-protected private keys.

Option 1: Unencrypted Private Key (No Passphrase)

This is the simplest approach for certificates without passphrase protection:

# Create certs directory and add your certificates
mkdir -p certs
cp /path/to/your/certificate.pem certs/cert.pem
cp /path/to/your/private-key.pem certs/key.pem

# Start the TLS stack (will use existing certs)
make compose-tls

The cert_init container detects existing certificates and skips generation.

Option 2: Passphrase-Protected Private Key

For enhanced security, you can use a passphrase-protected private key. The system will automatically decrypt it for nginx:

# Create certs directory and add your certificates
mkdir -p certs
cp /path/to/your/certificate.pem certs/cert.pem
cp /path/to/your/encrypted-key.pem certs/key-encrypted.pem

# Add passphrase to .env file
echo "KEY_FILE_PASSWORD=your-passphrase" >> .env

# Start the TLS stack (will auto-decrypt for nginx)
make compose-tls

How it works:

  • The cert_init container detects key-encrypted.pem and KEY_FILE_PASSWORD
  • It decrypts key-encrypted.pem to key.pem using openssl rsa
  • Nginx uses the decrypted key.pem (nginx doesn't support passphrase-protected keys natively)
  • Your original encrypted key remains safe in key-encrypted.pem

Option 3: Generate Self-Signed with Passphrase

To generate a new self-signed certificate with passphrase protection:

# Generate passphrase-protected certificate
make certs-passphrase

# Add passphrase to .env file
echo "KEY_FILE_PASSWORD=your-passphrase" >> .env

# Start the TLS stack
make compose-tls

This creates: - certs/cert.pem - Certificate file - certs/key-encrypted.pem - Passphrase-protected private key - certs/key.pem - Decrypted private key (generated by cert_init for nginx)

TLS Profile Commands

Command Description
make compose-tls Start with TLS (HTTP + HTTPS both available)
make compose-tls-https Start with forced HTTPS (HTTP redirects to HTTPS)
make compose-tls-down Stop TLS-enabled stack
make compose-tls-logs Tail logs from TLS services
make compose-tls-ps Show TLS stack status

Forcing HTTPS Redirect

To redirect all HTTP traffic to HTTPS (recommended for production):

# Option 1: Use the convenience command
make compose-tls-https

# Option 2: Set environment variable
NGINX_FORCE_HTTPS=true make compose-tls

When enabled, requests to http://localhost:8080 automatically redirect to https://localhost:8443.

Combining with Other Profiles

The TLS profile works alongside other Docker Compose profiles:

# TLS + Monitoring (Prometheus, Grafana, etc.)
docker compose --profile tls --profile monitoring up -d --scale nginx=0

# TLS + Benchmark servers
docker compose --profile tls --profile benchmark up -d --scale nginx=0

Scaling nginx to 0

When using --profile tls, add --scale nginx=0 to prevent the default nginx from conflicting with nginx_tls on port 8080.

Verifying TLS

# Test HTTP endpoint
curl http://localhost:8080/health

# Test HTTPS endpoint (skip cert verification for self-signed)
curl -sk https://localhost:8443/health

# Check TLS version
openssl s_client -connect localhost:8443 -brief 2>&1 | head -5

Expected output:

{"status":"healthy"}

Certificate Details

Auto-generated certificates include:

  • Validity: 365 days
  • Key size: RSA 4096-bit
  • Subject Alternative Names: localhost, gateway, nginx, 127.0.0.1
  • Protocols: TLS 1.2 and TLS 1.3

Overview

The MCP Gateway supports TLS/SSL encryption at multiple layers:

  1. Gateway TLS - Direct HTTPS connections to the gateway (port 4444)
  2. Nginx Frontend TLS - HTTPS connections from clients to nginx (port 8443)
  3. Nginx Backend TLS - HTTPS connections from nginx to the gateway backend

Architecture Options

Option 1: Gateway TLS Only (Simplest)

Client -> HTTPS (self-signed) -> Gateway (port 4444)
Best for: Development, testing, internal networks

Client -> HTTPS (trusted cert) -> Nginx (port 8443) -> HTTP -> Gateway
Best for: Production with trusted certificates, load balancing

Option 3: End-to-End TLS (Most Secure)

Client -> HTTPS (trusted cert) -> Nginx (port 8443) -> HTTPS (self-signed) -> Gateway
Best for: Zero-trust networks, defense in depth

This guide focuses on Option 3 (end-to-end TLS).

Prerequisites

  • Docker and Docker Compose installed
  • OpenSSL installed (for certificate generation)
  • Basic understanding of TLS/SSL concepts

Step 1: Generate SSL Certificates

Generate Self-Signed Certificates

For development and testing, generate self-signed certificates:

# Navigate to project root
cd /path/to/mcp-context-forge

# Generate certificates without passphrase (recommended for automated services)
make certs

# Or with passphrase protection (for gateway only - see note below)
make certs-passphrase

This creates:

  • certs/cert.pem - SSL certificate
  • certs/key.pem - Unencrypted private key
  • certs/key-encrypted.pem - Passphrase-protected private key (if using make certs-passphrase)

Understanding Passphrase-Protected Keys

Important Security Note: Passphrase-protected keys are designed for interactive systems where a human enters the passphrase at startup. In automated/containerized environments, they provide minimal security benefit because:

  • The passphrase must be stored alongside the key (in environment variables or files)
  • Any process that can read the key can also read the passphrase
  • They only protect keys "at rest" when the service is stopped

When passphrase protection helps:

  • The gateway (Gunicorn/Granian) can decrypt keys programmatically from environment variables
  • Adds marginal protection if an attacker gets read-only filesystem access but not environment access
  • Useful for compliance requirements that mandate encrypted keys at rest

Recommended approach:

  • Development/Testing: Use unencrypted keys (make certs)
  • Production: Use proper secrets management (see Security Best Practices section)

Set Passphrase in Environment (Optional)

If using passphrase-protected keys for the gateway:

# Add to .env file
KEY_FILE_PASSWORD=your-secure-passphrase

For Production: Use Trusted Certificates

For production deployments, obtain certificates from a trusted Certificate Authority (CA):

  • Let's Encrypt - Free automated certificates
  • Commercial CA - DigiCert, GlobalSign, etc.
  • Internal CA - For enterprise deployments

Place your certificates in the certs/ directory:

certs/
├── cert.pem          # Your certificate chain
├── key.pem           # Unencrypted key (for nginx)
└── key-encrypted.pem # Encrypted key (for gateway)

Step 2: Configure Gateway TLS

Environment Variables

Edit docker-compose.yml gateway service environment section:

gateway:
  environment:
    # Enable SSL
    - SSL=true
    - CERT_FILE=/app/certs/cert.pem
    - KEY_FILE=/app/certs/key-encrypted.pem
    - KEY_FILE_PASSWORD=${KEY_FILE_PASSWORD}  # From .env file

Mount Certificates

Ensure certificates are mounted in the gateway container:

gateway:
  volumes:
    - ./certs:/app/certs:ro   # Read-only mount

HTTP Server Selection

The gateway supports two HTTP servers with different TLS implementations:

Gunicorn (Default)

environment:
  - HTTP_SERVER=gunicorn

Gunicorn uses a custom Python SSL key manager that:

  • Decrypts passphrase-protected keys at startup
  • Creates temporary unencrypted key files
  • Supports all SSL/TLS configurations

Granian (Rust-based)

environment:
  - HTTP_SERVER=granian

Granian has native Rust TLS support:

  • Supports passphrase-protected keys via --ssl-keyfile-password
  • Better performance (Rust + Tokio)
  • Native HTTP/2 support

Both servers work identically from a client perspective.

Update Healthcheck

For HTTPS gateway, update the healthcheck to skip SSL verification for self-signed certificates:

gateway:
  healthcheck:
    test: ["CMD", "curl", "-fk", "https://localhost:4444/health"]
    interval: 30s
    timeout: 10s
    retries: 5
    start_period: 30s

Expose Gateway Port (Optional)

To access the gateway directly via HTTPS:

gateway:
  ports:
    - "4444:4444"  # Expose HTTPS port

Step 3: Configure Nginx TLS

Frontend TLS (Client → Nginx)

Edit infra/nginx/nginx.conf to add HTTPS listeners:

server {
    # HTTP listener (port 80)
    listen 80 backlog=4096 reuseport;
    listen [::]:80 backlog=4096 reuseport;

    # HTTPS listener (port 443)
    listen 443 ssl backlog=4096 reuseport;
    listen [::]:443 ssl backlog=4096 reuseport;

    # SSL certificate configuration
    ssl_certificate /app/certs/cert.pem;
    ssl_certificate_key /app/certs/key.pem;  # Use unencrypted key

    # SSL protocols and ciphers
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;

    # SSL session caching for performance
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    server_name localhost;

    # ... rest of configuration
}

Note on Nginx and Key Encryption: While nginx supports passphrase-protected keys via ssl_password_file, this requires storing the passphrase in a plaintext file, which provides no security benefit over using an unencrypted key with proper filesystem permissions. For automated services, use unencrypted keys and protect them via:

  • Filesystem permissions (mode 640 with group 0 for containers)
  • Read-only volume mounts
  • Secrets management systems (production)

Creating Unencrypted Key from Encrypted Key

If you have a passphrase-protected key and need an unencrypted version for nginx:

# Using make command (recommended - handles permissions automatically)
make certs-remove-passphrase

# Or manually with openssl
cd certs
openssl rsa -in key-encrypted.pem -out key.pem -passin pass:YOUR_PASSWORD
chmod 640 key.pem
sudo chgrp 0 key.pem  # Set group to 0 for container access

The make certs-remove-passphrase command will:

  1. Prompt for your passphrase to decrypt the key
  2. Create certs/key.pem with 640 permissions
  3. Automatically set group to 0 using sudo (you'll be prompted for password)

Backend TLS (Nginx → Gateway)

Configure nginx to connect to the gateway via HTTPS:

# Upstream configuration (before server block)
upstream gateway_backend {
    least_conn;
    server gateway:4444 max_fails=0;

    keepalive 512;
    keepalive_requests 100000;
    keepalive_timeout 60s;
}

# SSL Backend Configuration
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_verify off;  # Disable for self-signed certs
# Or enable verification with trusted CA:
# proxy_ssl_verify on;
# proxy_ssl_trusted_certificate /app/certs/ca-bundle.pem;
proxy_ssl_session_reuse on;

Update all proxy_pass directives to use HTTPS:

location / {
    proxy_pass https://gateway_backend;  # Changed from http:// to https://
    # ... other proxy settings
}

Mount Certificates in Nginx

In docker-compose.yml, mount certificates and config:

nginx:
  volumes:
    - nginx_cache:/var/cache/nginx
    - ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    - ./certs:/app/certs:ro  # Mount certificates

Expose Nginx HTTPS Port

nginx:
  ports:
    - "8080:80"    # HTTP
    - "8443:443"   # HTTPS

Update Nginx Dockerfile

Edit infra/nginx/Dockerfile to expose port 443:

# Expose HTTP and HTTPS ports
EXPOSE 80 443

Step 4: Deploy with TLS

Build and Start Services

Use the standard make commands to build and deploy:

# Build production container image
make docker-prod

# Start all services (validates compose file and starts stack)
make compose-up

# Check service status
docker compose ps

What these commands do:

  • make docker-prod - Builds the gateway container image using Containerfile.lite
  • make compose-up - Validates docker-compose.yml and starts all services in detached mode

These commands automatically handle image tagging and validation.

Verify Gateway Health

# Direct HTTPS to gateway (skip SSL verification for self-signed)
curl -fk https://localhost:4444/health

# Expected output: {"status":"healthy"}

Verify Nginx Health

# HTTP via nginx
curl -f http://localhost:8080/health

# HTTPS via nginx (skip SSL verification for self-signed)
curl -fk https://localhost:8443/health

# Expected output: {"status":"healthy"}

Step 5: Testing

Test Direct Gateway Access

# HTTPS to gateway
curl -fkv https://localhost:4444/health

# Check certificate details
openssl s_client -connect localhost:4444 -servername localhost < /dev/null

Test Nginx Proxy

# HTTP to nginx (proxies to HTTPS gateway)
curl -fv http://localhost:8080/health

# HTTPS to nginx (proxies to HTTPS gateway)
curl -fkv https://localhost:8443/health

Test with Browser

  1. Direct Gateway: https://localhost:4444/health

  2. Accept security warning for self-signed certificate

  3. Nginx HTTP: http://localhost:8080/health

  4. No security warning (plain HTTP)

  5. Nginx HTTPS: https://localhost:8443/health

  6. Accept security warning for self-signed certificate

Troubleshooting

TLS Handshake Errors

Symptom: Gateway logs show TLS handshake failures:

[INFO] TCP handshake failed with error: TlsAcceptError {
  error: Custom { kind: InvalidData, error: InvalidMessage(InvalidContentType) },
  peer_addr: 172.18.0.X:XXXXX
}

Cause: Nginx's keepalive connection pool is trying to reuse old HTTP connections to the now-HTTPS gateway.

Solution: Restart nginx to clear the connection pool:

docker compose restart nginx

502 Bad Gateway from Nginx

Symptom: Nginx returns 502 errors when proxying to gateway.

Possible Causes:

  1. Nginx using HTTP to connect to HTTPS gateway

  2. Check: proxy_pass should be https://gateway_backend

  3. Fix: Update nginx.conf and restart: docker compose restart nginx

  4. SSL certificate hostname mismatch

  5. Check nginx error logs: docker compose logs nginx

  6. Error: upstream SSL certificate does not match "gateway_backend"
  7. Fix: Set proxy_ssl_verify off; in nginx.conf for self-signed certs

  8. Missing certificates in nginx container

  9. Check: Verify volume mount in docker-compose.yml

  10. Fix: Add - ./certs:/app/certs:ro to nginx volumes

Gateway Healthcheck Failing

Symptom: docker compose ps shows gateway as "unhealthy"

Cause: Healthcheck using HTTP to check HTTPS endpoint, or not skipping SSL verification.

Fix: Update healthcheck in docker-compose.yml:

gateway:
  healthcheck:
    test: ["CMD", "curl", "-fk", "https://localhost:4444/health"]

The -k flag skips SSL certificate verification for self-signed certificates.

Passphrase Errors with Gunicorn

Symptom: Gunicorn fails to start with SSL key errors.

Cause: Missing or incorrect KEY_FILE_PASSWORD environment variable.

Fix:

  1. Verify .env file contains: KEY_FILE_PASSWORD=your-passphrase
  2. Verify docker-compose.yml references it: - KEY_FILE_PASSWORD=${KEY_FILE_PASSWORD}
  3. Restart gateway: docker compose restart gateway

Granian TLS Errors

Symptom: Granian fails to start or shows TLS initialization errors.

Cause: Passphrase not passed correctly to Granian.

Fix: Granian expects the passphrase via environment variable. Ensure KEY_FILE_PASSWORD is set in .env and referenced in docker-compose.yml.

Connection Refused

Symptom: curl: (7) Failed to connect to localhost port 4444: Connection refused

Cause: Gateway port not exposed or service not started.

Fix:

  1. Check service is running: docker compose ps gateway
  2. Check port mapping: Should show 0.0.0.0:4444->4444/tcp
  3. Uncomment ports in docker-compose.yml if commented

Self-Signed Certificate Warnings

Symptom: Browser shows "Your connection is not private" warning.

Expected Behavior: This is normal for self-signed certificates in development.

Solutions:

  • Development: Click "Advanced" → "Proceed to localhost (unsafe)"
  • CI/CD: Use curl -k flag to skip verification
  • Production: Use trusted certificates from a CA

Clean Network and Reset

If you encounter persistent connection errors after changing TLS configuration:

Full Reset Procedure

# Stop all services
docker compose down

# Remove network (clears all connection pools)
docker network rm mcp-context-forge_mcpnet

# Recreate and start services
docker compose up -d

# Verify health
docker compose ps
curl -fk https://localhost:4444/health
curl -f http://localhost:8080/health
curl -fk https://localhost:8443/health

Selective Service Restart

If only nginx has stale connections:

# Stop and start (recreates container)
docker compose stop nginx
docker compose start nginx

If gateway needs fresh connections:

docker compose restart gateway

Security Best Practices

Development and Testing

  1. Protect keys with filesystem permissions

The make certs and make certs-passphrase commands automatically set correct permissions:

# Permissions set automatically by make commands:
chmod 644 certs/cert.pem          # Public certificate - world-readable is OK
chmod 640 certs/key.pem           # Private key - owner+group only, no world access
sudo chgrp 0 certs/key.pem        # Set group to 0 for container access

Why group 0 and 640? - Container runs as UID 1001, GID 0 (see Containerfile.lite line 236, 271) - Host files are owned by your UID (e.g., 1000), not container UID (1001) - By setting group to 0 (root group) and permissions to 640:

 - Owner (your UID): Read/write access ✅
 - Group 0: Read access ✅ ← **Container (1001:0) accesses via this**
 - Others: No access ✅
  • More secure than 644 (no world access)

Sudo requirement: The make commands use sudo chgrp 0 to set the group. You'll be prompted for your password once. If sudo is not available, you'll see a warning but cert generation will still succeed (you can set the group manually later).

  1. Never commit secrets to git

  2. Certificate files are in .gitignore

  3. Never commit .env files with passwords/passphrases
  4. Use .env.example as a template without real secrets

  5. Use read-only volume mounts

    volumes:
      - ./certs:/app/certs:ro  # :ro = read-only
    

  6. Rotate certificates regularly (every 90 days minimum, even for self-signed)

  7. Passphrase protection consideration

  8. Passphrase-protected keys provide minimal benefit in automated environments

  9. Only protects keys when service is stopped
  10. Consider for compliance requirements or gateway-only deployments

Production

  1. Use trusted CA certificates (Let's Encrypt, commercial CA, internal PKI)

  2. Let's Encrypt is free and automated

  3. Commercial CAs for enterprise support
  4. Internal CA for private networks

  5. Implement proper secrets management

  6. Kubernetes: Use Secrets with RBAC

    apiVersion: v1
    kind: Secret
    metadata:
      name: tls-secret
    type: kubernetes.io/tls
    data:
      tls.crt: <base64-encoded-cert>
      tls.key: <base64-encoded-key>
    

  7. Docker Swarm: Use Docker Secrets

    docker secret create tls_cert cert.pem
    docker secret create tls_key key.pem
    

  8. HashiCorp Vault: Store and inject secrets at runtime

  9. Cloud KMS: AWS KMS, Google Cloud KMS, Azure Key Vault

  10. Enable certificate validation for backend connections

    proxy_ssl_verify on;
    proxy_ssl_trusted_certificate /etc/ssl/certs/ca-bundle.crt;
    

  11. Use strong TLS protocols (TLSv1.2 and TLSv1.3 only)

  12. Disable SSLv3, TLS 1.0, TLS 1.1

  13. Use modern cipher suites

  14. Implement certificate rotation automation

  15. Let's Encrypt with certbot (auto-renewal)

  16. cert-manager for Kubernetes
  17. Custom rotation scripts

  18. Monitor certificate expiration (30 days warning minimum)

  19. Prometheus + alertmanager

  20. Cloud monitoring services
  21. Custom monitoring scripts

  22. Use HSTS headers to enforce HTTPS

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    

  23. Consider certificate pinning for highly sensitive deployments

  24. Public Key Pinning Extension (HPKP) - deprecated

  25. Certificate Transparency monitoring
  26. Application-level pinning

  27. Use Hardware Security Modules (HSM) for high-security environments

  28. Cloud HSM services (AWS CloudHSM, Azure Dedicated HSM)

  29. On-premises HSMs
  30. PKCS#11 integration with nginx/gateway

Key Security Reality

Important: For automated services (containers, VMs, cloud), key security relies on:

  1. Access control - Who can read the key file or secret
  2. Filesystem permissions - Unix permissions with proper group ownership

  3. Containers: 640 with group 0 (owner+group, no world access)

  4. Container user (UID 1001, GID 0) accesses via group membership

  5. Secrets management - Proper injection and rotation

  6. Network isolation - Limit exposure of TLS endpoints
  7. Audit logging - Track secret access

Passphrase protection does NOT add security when the passphrase must be stored alongside the key. The real security boundary is access control to the secret store (filesystem, Kubernetes API, Vault, etc.).

Certificate Management

# Check certificate expiration
openssl x509 -in certs/cert.pem -noout -dates

# Verify certificate matches private key
openssl x509 -noout -modulus -in certs/cert.pem | openssl md5
openssl rsa -noout -modulus -in certs/key.pem | openssl md5
# Output should match

# View certificate details
openssl x509 -in certs/cert.pem -text -noout

Configuration Examples

Complete docker-compose.yml TLS Configuration

services:
  gateway:
    image: mcpgateway/mcpgateway:latest
    ports:
      - "4444:4444"
    environment:
      # TLS Configuration
      - SSL=true
      - CERT_FILE=/app/certs/cert.pem
      - KEY_FILE=/app/certs/key-encrypted.pem
      - KEY_FILE_PASSWORD=${KEY_FILE_PASSWORD}

      # HTTP Server (gunicorn or granian)
      - HTTP_SERVER=granian

      # Other settings...
      - HOST=0.0.0.0
      - PORT=4444
    volumes:
      - ./certs:/app/certs:ro
    healthcheck:
      test: ["CMD", "curl", "-fk", "https://localhost:4444/health"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 30s

  nginx:
    image: mcpgateway/nginx-cache:latest
    ports:
      - "8080:80"    # HTTP
      - "8443:443"   # HTTPS
    volumes:
      - ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/app/certs:ro
      - nginx_cache:/var/cache/nginx
    depends_on:
      gateway:
        condition: service_healthy

volumes:
  nginx_cache:

Complete nginx.conf TLS Configuration

upstream gateway_backend {
    least_conn;
    server gateway:4444 max_fails=0;
    keepalive 512;
    keepalive_requests 100000;
    keepalive_timeout 60s;
}

# Backend SSL configuration
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_verify off;
proxy_ssl_session_reuse on;

server {
    # HTTP listener
    listen 80 backlog=4096 reuseport;
    listen [::]:80 backlog=4096 reuseport;

    # HTTPS listener
    listen 443 ssl backlog=4096 reuseport;
    listen [::]:443 ssl backlog=4096 reuseport;

    # SSL certificates
    ssl_certificate /app/certs/cert.pem;
    ssl_certificate_key /app/certs/key.pem;

    # SSL configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    server_name localhost;

    location / {
        proxy_pass https://gateway_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Additional Resources