Behind a Reverse Proxy

Nginx, Cloudflare, Caddy — Doing the Heavy Lifting

Behind a Reverse Proxy

In production, Express usually sits behind a reverse proxy. Set trust proxy correctly and you're 90% done.

4 min read Level 2/5 #express#nginx#reverse-proxy
What you'll learn
  • Configure trust proxy
  • Use real client IPs and protocol
  • Coordinate gzip / TLS / static

Most production Express runs behind a reverse proxy — Nginx, Caddy, Cloudflare, or your cloud’s load balancer. The proxy handles:

  • TLS termination (HTTPS)
  • HTTP/2 and HTTP/3
  • gzip / brotli compression
  • Static file serving
  • Caching
  • DDoS protection

Express just speaks HTTP over localhost to the proxy.

trust proxy

When the proxy forwards a request, it adds headers:

X-Forwarded-For:  203.0.113.42
X-Forwarded-Proto: https
X-Forwarded-Host: api.example.com

Without trust proxy, Express ignores these:

  • req.ip is the proxy’s IP (useless for rate limits)
  • req.protocol is http (cookies won’t get Secure)
  • req.hostname is wrong

Tell Express to trust the proxy:

app.set("trust proxy", 1);   // 1 hop: direct from one trusted proxy

Now req.ip is the real client, req.protocol === "https" works, and req.secure === true when appropriate.

Trust Levels

trust proxy accepts:

  • false — don’t trust anything (default)
  • true — trust everything (dangerous if exposed directly)
  • An integer (1, 2) — number of trusted proxies
  • A string of IPs / subnets
  • A function

For most setups, 1 is right — you have one proxy in front of Express.

A Sample Nginx Config

server {
  listen 443 ssl http2;
  server_name api.example.com;

  ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

  # Static files served directly
  location /assets/ {
    alias /var/www/assets/;
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  # Everything else goes to Node
  location / {
    proxy_pass http://localhost: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_set_header X-Forwarded-Host  $host;

    # WebSocket upgrade
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
  }
}

SSE / WebSockets Behind Nginx

Two important tweaks:

location /events {
  proxy_pass http://localhost:3000;
  proxy_buffering off;           # critical for SSE
  proxy_set_header Connection "";
  proxy_http_version 1.1;
}

Without proxy_buffering off, Nginx buffers your SSE events and your client sits silent.

Health Checks From the Proxy

The proxy will hit your app every few seconds. Make /healthz cheap:

app.get("/healthz", (req, res) => res.json({ ok: true }));

Don’t run a DB query in /healthz — it gets hit constantly. Put DB checks in /readyz instead, hit less often.

When No Proxy

In dev or on platforms (Render, Fly, Railway), the platform IS the proxy. They set X-Forwarded-* headers. Set trust proxy as above and let them do their job.

Deployment →