Guide · Deployment

MCP server nginx reverse proxy

nginx is the most common reverse proxy in front of Node.js MCP servers running on Linux VPS instances. The configuration for MCP differs from a standard REST API in three ways: SSE transport requires proxy_buffering off (otherwise nginx buffers the response stream and the client never receives SSE events), proxy_read_timeout must be set to the expected session duration (the default 60 seconds terminates sessions that are idle between tool calls), and upstream keepalive connections eliminate the TCP handshake overhead for every MCP request. This guide covers the full nginx configuration for an MCP server, including TLS with Let's Encrypt, rate limiting at the nginx layer, and the health check probe route that AliveMCP uses.

TL;DR

Three critical settings for MCP on nginx: proxy_buffering off (SSE requires streaming, not buffered responses), proxy_read_timeout 3600s (long-lived SSE sessions), and proxy_http_version 1.1 with proxy_set_header Connection "" (keepalive to upstream). Add TLS via Certbot, rate-limit with limit_req_zone, and allow the AliveMCP probe IP through any IP allowlist.

Why nginx for MCP servers

The MCP server process (Node.js) binds to a high port (3000) as a non-root user. nginx runs as root (to bind port 443) and proxies to the Node process. This separation means TLS termination, rate limiting, request logging, and certificate management are handled by nginx without the MCP server code needing to deal with them. The MCP server receives plain HTTP on the loopback interface and does not need to handle TLS, certificate renewal, or IP-based rate limiting.

ConcernHandled by nginxHandled by MCP server
TLS terminationYes — Let's Encrypt certNo — plain HTTP internally
HTTP → HTTPS redirectYesNo
Rate limiting per IPYes — limit_req_zoneOptional — per-session
Request loggingYes — access.logApplication-level structured log
Static file servingYes — docs, status pageNo
MCP protocolProxied throughYes
AuthNoYes — JWT, API key middleware

Core nginx configuration

# /etc/nginx/sites-available/mcp-server
# Symlink to /etc/nginx/sites-enabled/mcp-server to activate

# Rate limiting zones — defined once at http block level (in nginx.conf or this file)
limit_req_zone $binary_remote_addr zone=mcp_per_ip:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=mcp_health:1m  rate=5r/s;

# Upstream pool for the MCP server process
# keepalive maintains a pool of persistent connections to the upstream
# reducing per-request TCP overhead for JSON-RPC calls
upstream mcp_server {
  server 127.0.0.1:3000;
  keepalive 16;   # maintain up to 16 idle keepalive connections to the upstream
}

# HTTP → HTTPS redirect
server {
  listen 80;
  server_name your-mcp-server.example.com;
  return 301 https://$host$request_uri;
}

# Main HTTPS server block
server {
  listen 443 ssl http2;
  server_name your-mcp-server.example.com;

  # TLS — certificate managed by Certbot (see below)
  ssl_certificate     /etc/letsencrypt/live/your-mcp-server.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/your-mcp-server.example.com/privkey.pem;
  include             /etc/letsencrypt/options-ssl-nginx.conf;
  ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;

  # Security headers
  add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
  add_header X-Content-Type-Options nosniff always;
  add_header X-Frame-Options DENY always;

  # Health check endpoint — higher rate limit, no request body buffering
  location /health {
    limit_req zone=mcp_health burst=10 nodelay;
    proxy_pass http://mcp_server;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
  }

  # MCP SSE endpoint — critical settings for long-lived connections
  location /sse {
    limit_req zone=mcp_per_ip burst=5 nodelay;

    proxy_pass         http://mcp_server;
    proxy_http_version 1.1;

    # Required for SSE: disable response buffering
    # Without this, nginx buffers the SSE stream and clients receive
    # events in batches rather than in real time (or not at all)
    proxy_buffering    off;

    # Long timeout for SSE connections — SSE is a long-lived HTTP response
    # The session may be idle (no events) for minutes between tool calls
    # 3600s = 1 hour — adjust down to match your max expected session duration
    proxy_read_timeout  3600s;
    proxy_send_timeout  3600s;

    # Keep-alive to upstream (required for keepalive pool to work)
    proxy_set_header Connection "";

    # Pass the real client IP to the MCP server for logging and rate limiting
    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;
  }

  # All other MCP routes (JSON-RPC over HTTP, tools/list, etc.)
  location / {
    limit_req zone=mcp_per_ip burst=20 nodelay;

    proxy_pass         http://mcp_server;
    proxy_http_version 1.1;
    proxy_set_header   Connection "";
    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;

    # Standard timeouts for non-SSE routes
    proxy_read_timeout  30s;
    proxy_send_timeout  30s;

    # Buffer request body on disk to protect the upstream from slow clients
    proxy_request_buffering on;
    client_max_body_size 10m;  # limit request body for JSON-RPC payloads
  }
}

The limit_req_zone directives must be in the http context (in /etc/nginx/nginx.conf or an included file), not inside a server block. If you include this file directly, move the limit_req_zone lines into nginx.conf.

TLS with Let's Encrypt Certbot

# Install Certbot with the nginx plugin
apt install certbot python3-certbot-nginx

# Obtain a certificate and automatically update nginx config
certbot --nginx -d your-mcp-server.example.com

# Certbot adds the ssl_certificate directives to your server block automatically
# and sets up a systemd timer for auto-renewal

# Verify auto-renewal works
certbot renew --dry-run

# Check renewal timer
systemctl status certbot.timer

Certbot renews certificates 30 days before expiry by default. Renewal triggers an nginx reload (not restart), so active SSE connections are not interrupted. AliveMCP validates your TLS certificate on every probe cycle and alerts you if the certificate is within 14 days of expiry. See MCP server SSL certificate monitoring for the certificate validation probe details.

Reading real client IP behind the proxy

When nginx proxies requests, the MCP server sees nginx's loopback IP (127.0.0.1) as the client address, not the real client IP. This breaks per-client rate limiting and structured log correlation. Configure the MCP server to trust the X-Forwarded-For header from localhost only.

// In your Fastify/Express MCP server:

// Fastify — trust proxy headers from loopback
const app = Fastify({
  trustProxy: '127.0.0.1',  // only trust X-Forwarded-For from nginx on localhost
  logger: true,
});

// Access the real IP in request handlers
app.addHook('onRequest', async (req) => {
  const realIp = req.ip;  // resolved from X-Real-IP or X-Forwarded-For
  req.log.info({ client_ip: realIp }, 'Request received');
});

// Express — equivalent
import { rateLimit } from 'express-rate-limit';
app.set('trust proxy', '127.0.0.1');
app.use(rateLimit({
  keyGenerator: (req) => req.ip,  // req.ip is the real IP after trust proxy
  windowMs: 60 * 1000,
  max: 30,
}));

Never set trustProxy: true (trusts all headers from any source). An attacker can forge X-Forwarded-For to impersonate any IP, bypassing per-IP rate limits. Only trust forwarded headers from your known reverse proxy address. See MCP server rate limiting for the full rate limiting implementation.

nginx access log format for MCP

# Add to /etc/nginx/nginx.conf in the http block
log_format mcp_json escape=json
  '{'
    '"time":"$time_iso8601",'
    '"method":"$request_method",'
    '"uri":"$request_uri",'
    '"status":$status,'
    '"bytes_sent":$bytes_sent,'
    '"duration_ms":$request_time,'
    '"client_ip":"$remote_addr",'
    '"real_ip":"$http_x_real_ip",'
    '"user_agent":"$http_user_agent",'
    '"ssl_protocol":"$ssl_protocol",'
    '"ssl_cipher":"$ssl_cipher"'
  '}';

# In the server block:
access_log /var/log/nginx/mcp-server-access.log mcp_json;
error_log  /var/log/nginx/mcp-server-error.log warn;

JSON-formatted nginx logs integrate directly with Promtail or Filebeat for log aggregation. The request_time field in seconds (fractional) captures the full request duration including SSE stream duration — useful for detecting abnormally long sessions. See MCP server log aggregation for the Loki + Grafana pipeline that ingests nginx JSON logs.

Testing the nginx configuration

# Test the configuration for syntax errors before reloading
nginx -t

# Reload nginx without dropping connections (sends SIGHUP)
# This is safe during active SSE sessions — nginx keeps existing connections alive
# and only new connections use the updated configuration
systemctl reload nginx

# Verify the proxy is working
curl -v https://your-mcp-server.example.com/health

# Check that SSE buffering is disabled (response headers should not include Content-Encoding)
curl -I https://your-mcp-server.example.com/sse

# Confirm TLS grade
openssl s_client -connect your-mcp-server.example.com:443 -brief 2>/dev/null | head -5

Related questions

Should I use nginx or Caddy for my MCP server?

Caddy is simpler to configure for simple cases (automatic HTTPS, minimal config file) and is a good choice if you are setting up a small MCP server quickly. nginx is more configurable, has better documentation for edge cases (upstream keepalive pools, complex rate limiting zones, fine-grained proxy timeouts), and is the default choice in most Linux server environments. If you already have nginx running on your VPS, add an MCP server block. If you are starting fresh, Caddy's automatic TLS makes it easier to get started. See MCP server deployment for the Caddy equivalent of this nginx configuration.

How do I configure nginx for WebSocket transport (not SSE)?

WebSocket transport requires the Upgrade and Connection: Upgrade headers to be forwarded. Add to the WebSocket location block: proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";. The timeout concern is the same as SSE — set proxy_read_timeout to match your expected session duration. See MCP server WebSockets for the full WebSocket transport configuration.

How do I allow AliveMCP's probe through a firewall?

AliveMCP probes from a stable set of egress IPs published at mcp-server-uptime-monitoring. Add those IPs to your firewall allowlist. Do not restrict access to /health by IP — health endpoints should be publicly accessible so monitoring services can probe them without IP allowlist maintenance. Rate-limit /health (as shown in this guide's nginx config) but do not block it. If you restrict your MCP server by IP (enterprise private endpoints), set up AliveMCP's private monitoring mode which probes from a static IP you allowlist once.

How do I configure nginx for multiple MCP servers on the same host?

Add one server block per MCP server, each with a different server_name (different domain or subdomain). Each block proxies to a different upstream port (3000, 3001, 3002). Certbot manages one certificate per domain. For multiple subdomains on the same domain, use a wildcard certificate: certbot --nginx -d "*.your-domain.com" (requires DNS challenge, not HTTP challenge). All limit_req_zone directives share the same namespace — use distinct zone names per server to avoid cross-server rate limit interference.

Further reading