Recipe-Inhalt ist auf Englisch. Englisches Original lesen →
← Alle Recipes
Phase 8 · Deploy MCP·8 steps

Nginx + Let's Encrypt, production TLS in 10 lines

DNS-01 wildcard certs via Cloudflare, HSTS + security headers, the 3600s SSE timeout, the Cloudflare real-IP setup.

8 steps0%
Du liest ohne Account. Mit Login speichern wir Step-Fortschritt + Notes.

Nginx + Let's Encrypt, production TLS in 10 lines

Your container listens on localhost:3000. Nginx terminates TLS, adds security headers, and reverse-proxies to it. Let's Encrypt issues the cert for free, certbot renews it automatically. This recipe is the 10-line nginx config plus the wildcard-via-DNS-01 setup that makes subdomains free.

Schritt 1: Install nginx + certbot

# On Ubuntu/Debian
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx

For wildcard certs (covered in Step 4), also:

sudo apt install -y python3-certbot-dns-cloudflare

Schritt 2: The minimal vhost

# /etc/nginx/sites-available/your-mcp.io.conf
server {
    listen 80;
    server_name your-mcp.io;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name your-mcp.io;

    ssl_certificate     /etc/letsencrypt/live/your-mcp.io/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-mcp.io/privkey.pem;

    # HSTS, enforce HTTPS for 1 year, include subdomains, preloadable
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_http_version 1.1;
        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;
        proxy_read_timeout 3600s;            # SSE streams
        proxy_buffering    off;              # don't buffer SSE chunks
    }
}

Then enable + test + reload:

sudo ln -s /etc/nginx/sites-available/your-mcp.io.conf /etc/nginx/sites-enabled/
sudo nginx -t  # syntax check
sudo systemctl reload nginx

Schritt 3: Single-domain cert via HTTP-01

Easiest path. Certbot puts a challenge file in /var/www/html/.well-known/acme-challenge/ and Let's Encrypt fetches it:

sudo certbot --nginx -d your-mcp.io --non-interactive --agree-tos -m [email protected]

After this:

  • Cert lives in /etc/letsencrypt/live/your-mcp.io/.
  • nginx vhost auto-updated to point at it.
  • Renewal cron is installed (certbot.timer on systemd).

sudo certbot renew --dry-run confirms renewal will work without actually renewing.

Schritt 4: Wildcard cert via DNS-01 (Cloudflare)

Wildcard certs (*.your-mcp.io) need DNS-01. Let's Encrypt verifies you control DNS, not just the HTTP endpoint.

Create Cloudflare API token (DNS:Edit on your zone) at https://dash.cloudflare.com/profile/api-tokens, then:

# Save credentials
sudo mkdir -p /root/.secrets
sudo tee /root/.secrets/cloudflare.ini > /dev/null <<EOF
dns_cloudflare_api_token = <your-token>
EOF
sudo chmod 600 /root/.secrets/cloudflare.ini

# Issue wildcard cert
sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
  -d 'your-mcp.io' -d '*.your-mcp.io' \
  --non-interactive --agree-tos -m [email protected]

Cert at /etc/letsencrypt/live/your-mcp.io/fullchain.pem. Update vhosts to point at it.

Auto-renewal works the same way. Wildcard saves you from issuing a new cert per subdomain, important if you have user-subdomains or many environments.

Schritt 5: Cloudflare proxy + real-IP

If you put Cloudflare in front (orange cloud), you get DDoS protection + caching for free, but $remote_addr becomes Cloudflare's IP, not the user's. Two pieces:

a) Trust Cloudflare's IPs as real-IP source:

# /etc/nginx/conf.d/cloudflare-realip.conf
# Cloudflare IPv4 ranges (current as of 2026, check https://www.cloudflare.com/ips/)
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
# ... (full list at the URL above; ~15 ranges)
real_ip_header CF-Connecting-IP;

b) Use CF-Connecting-IP in your app:

const realIp = req.headers['cf-connecting-ip'] || req.headers['x-real-ip'] || req.socket.remoteAddress;

Without these, your fail2ban rate-limits Cloudflare's IPs (and bans the entire CF edge from reaching your origin, we did this once).

Schritt 6: SSE-friendly config

The two settings that matter for MCP Streamable HTTP:

  • proxy_read_timeout 3600s. SSE responses are long-polling; default 60s would cut them mid-stream.
  • proxy_buffering off, nginx buffers responses by default; for SSE you want chunks delivered immediately.

Both are in the vhost above. Without them, MCP works for short tool calls but breaks on long-running ones.

Schritt 7: Verify

Run academy_validate_step. The validator hits https://your-mcp.io/health, if you get 200, the whole chain (DNS → CF → nginx → container) is wired.

Plus the manual checks:

# 1. nginx config syntax
sudo nginx -t

# 2. cert validity
sudo certbot certificates
# → Expiry Date: ..., 89 days

# 3. SSL grade
curl -sI https://your-mcp.io | head -5
# Expect HTTP/2 200, strict-transport-security header

# 4. Cipher suite check
nmap --script ssl-enum-ciphers -p 443 your-mcp.io 2>/dev/null | grep -E '(TLSv|cipher)'
# Expect TLSv1.2 / TLSv1.3 with modern ciphers, no SSLv3 / TLSv1.0

For a deeper grade, run https://www.ssllabs.com/ssltest/, aim for A or A+.

Client-Check · auf Deinem Rechner ausführen
(grep -RnE "[\x22\x27\x60]/health[\x22\x27\x60]" src/saas/server.ts src/server.ts src/http.ts 2>/dev/null | head -3) || echo "no /health route in source"
Erwartet: A `/health` route literal appears in src/saas/server.ts or src/server.ts.
Falls hängen geblieben: Add a simple `GET /health` returning `{ status: "ok", version: "..." }` so Docker / Cloud Run can probe liveness.

Schritt 8: Auto-renewal verification

sudo systemctl status certbot.timer
# Should be Active: active (waiting)

sudo certbot renew --dry-run
# All certificates renew successfully (dry run)

If the dry-run fails, your cert won't auto-renew. Fix before the cert expires (certbot emails you 20 + 10 + 1 days ahead).

Common traps

  • Forgetting proxy_read_timeout 3600s. SSE breaks at 60s.
  • proxy_buffering on (default). SSE chunks arrive in batches.
  • No set_real_ip_from for Cloudflare, fail2ban bans CF IPs, breaking your site for everyone.
  • HTTP-01 challenge for wildcard cert, won't work, Let's Encrypt requires DNS-01.
  • Self-signed cert in production. Claude Desktop refuses to connect over self-signed SSL.
  • Cert expired because certbot.timer is inactive, systemctl enable --now certbot.timer.
  • Multiple add_header directives in the same location, nginx ignores all but the last. Put security headers at server level.

What good looks like

https://your-mcp.io returns HTTP/2 200, valid Let's Encrypt cert (89-day expiry, auto-renewing), SSL Labs grade A or A+. nginx config is one file per vhost in sites-available, symlinked into sites-enabled. Certbot renewal is automatic and verified weekly via certbot renew --dry-run.

The whole TLS layer should be set-and-forget for 90 days at a time.

Docker Compose, direct hosting/health endpoint, liveness vs