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.
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+.
(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"
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_fromfor 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.timeris inactive,systemctl enable --now certbot.timer. - Multiple
add_headerdirectives in the samelocation, 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.