---
title: "Nginx + Let's Encrypt, production TLS in 10 lines | Recipe — StudioMeyer Academy"
description: "DNS-01 wildcard certs via Cloudflare, HSTS + security headers, the 3600s SSE timeout, the Cloudflare real-IP setup."
canonical: "https://studiomeyer.academy/en/recipes/8.3-nginx-proxy"
language: en
markdown_versions: ["https://studiomeyer.academy/en/recipes/8.3-nginx-proxy.md"]
publisher: "StudioMeyer Academy, https://studiomeyer.academy (llms.txt: https://studiomeyer.academy/llms.txt)"
---

[← Alle Recipes](https://studiomeyer.academy/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](https://studiomeyer.academy/login?next=/recipes/8.3-nginx-proxy) 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.

### Step 1: Install nginx + certbot

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

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

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

### Step 2: The minimal vhost

```nginx
# /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:

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

### Step 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:

```bash
sudo certbot --nginx -d your-mcp.io --non-interactive --agree-tos -m you@example.com
```

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.

### Step 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:

```bash
# 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 you@example.com
```

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.

### Step 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:**

```nginx
# /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:**

```typescript
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).

### Step 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.

### Step 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:

```bash
# 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 · run on your machine

(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"

**Expect:** A \`/health\` route literal appears in src/saas/server.ts or src/server.ts.

**If stuck:** Add a simple \`GET /health\` returning \`{ status: "ok", version: "..." }\` so Docker / Cloud Run can probe liveness.

### Step 8: Auto-renewal verification

```bash
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](https://studiomeyer.academy/recipes/8.2-docker-compose) [/health endpoint, liveness vs →](https://studiomeyer.academy/recipes/8.4-health-endpoint)

Phase 8

MCPize Cloud Run, Docker Compose, health endpoints, blue-green

[Alle Phasen →](https://studiomeyer.academy/recipes)

Concepts

[MCPize](https://studiomeyer.academy/concepts/mcpize)

## Sitemap

Public pages of this site as Markdown: [sitemap](https://studiomeyer.academy/sitemap.md). Curated entry point: [llms.txt](https://studiomeyer.academy/llms.txt), full text: [llms-full.txt](https://studiomeyer.academy/llms-full.txt).
