Half of the Nginx configs you find online work by accident. They set proxy_pass, add a couple of headers, and move on — until an upload times out, a WebSocket drops, or a client IP shows up as 127.0.0.1 in your logs. The difference between “works” and “works and tells the truth” is a handful of directives. Here’s the config I actually run, and the reasoning behind each piece.

Why you want a reverse proxy at all

One server, many apps, one TLS terminator. That’s the whole pitch. You run each app on a loopback port (3000, 8000, 8080), and Nginx routes by server_name or location. SSL, header rewriting, gzip, and rate limiting all live in one place instead of being duplicated across your apps.

example.com   → 127.0.0.1:3000  (Next.js)
api.example.com → 127.0.0.1:8000 (Node API)

The header block that actually matters

The default proxy behavior strips a lot. The response works, but the upstream app doesn’t know who’s asking or whether it was HTTP or HTTPS, which breaks redirects, rate limiting, and logs. Always forward the real values:

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;
  • X-Real-IP is the peer IP Nginx saw.
  • X-Forwarded-For chains via $proxy_add_x_forwarded_for, appending the real client IP to any existing chain — correct when there’s another proxy in front.
  • X-Forwarded-Proto is the one people forget. Without it, an app behind TLS sees plain HTTP and builds http:// redirects and canonical URLs. Express/Fastify explicitly read this header to decide whether a request is secure.

For WebSockets (and Next.js dev HMR), add the upgrade headers and force HTTP/1.1:

proxy_http_version 1.1;
proxy_set_header Upgrade    $http_upgrade;
proxy_set_header Connection "upgrade";

Without proxy_http_version 1.1, the upstream gets HTTP/1.0 and Connection: upgrade is useless. This is the single most common cause of “socket hang up” in a proxied WebSocket app.

A complete hardened server block

Here’s a config for a single app, HTTPS-first, with the details filled in:

# /etc/nginx/sites-available/example.com
server {
    listen 80;
    server_name example.com www.example.com;
    # Certbot will replace this block on first TLS setup
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com www.example.com;

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

    # Speed: keep connections to the app alive
    keepalive_timeout 65;

    # Proxy to the app
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";
        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;

        # Let the app finish, don't kill long requests
        proxy_read_timeout 60s;
    }

    # Basic security headers
    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;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Compression
    gzip on;
    gzip_types text/plain text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024;
}

The trailing-slash gotcha that bites everyone

proxy_pass treats a trailing slash as a URL prefix. This is the classic source of confusing 404s:

# passes /app/foo  →  http://upstream/app/foo
proxy_pass http://127.0.0.1:3000/;

# passes /app/foo  →  http://upstream/foo
proxy_pass http://127.0.0.1:3000;

With location /app/ { proxy_pass http://127.0.0.1:3000/; }, the location match replaces the path prefix location /app/ with whatever follows proxy_pass’s slash. If you want to strip /app, use the trailing slash. If you want to preserve it, don’t. When in doubt, test with curl -v and read the request line the upstream receives.

TLS: use Let’s Encrypt, not a half-configured cert

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run

Certbot’s nginx plugin is an installer: it edits your server block to serve HTTPS with the new certificate.[1] After the first run, re-add any custom directives (like http2 on and security headers) — Certbot won’t insert them for you. Check the resulting block before you consider it done.

A couple of TLS notes that are easy to overthink:

  • Strict-Transport-Security (max-age=31536000) tells browsers to require HTTPS. It’s safe once your cert is working, but don’t set it before you’ve confirmed all subdomains serve HTTPS, or you’ll lock users out of an insecure page.
  • Keep the defaults for ssl_protocols / ciphers; modern nginx + a fresh Let’s Encrypt cert already scores A+ on the common SSL test as long as you haven’t forced old TLS 1.0/1.1.

Tuning that actually moves the needle

Upstream keepalive — a connection pool so Nginx doesn’t open a fresh TCP handshake to your app for every request. Put this in an upstream block and reference it by name:

upstream myapp {
    server 127.0.0.1:3000;
    keepalive 16;
}
server {
    location / {
        proxy_pass http://myapp;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

Setting Connection "" inside the location keeps the upstream connection alive (the upgrade header above is the exception — do it only when you need WebSockets).

proxy_buffering — when proxying a stream (SSE, a large download) or a long-poll endpoint, you may need proxy_buffering off; so bytes flow through instead of being buffered and delivered late.

client_max_body_size — the default is 1 MB. Any upload API behind the proxy will 413 the moment you exceed it. Set it to what your app actually accepts:

client_max_body_size 50m;

worker_connections — in nginx.conf, this caps the number of simultaneous connections per worker; the default is 512.[2] That’s fine for a small site but is the first thing to check under a load spike. Size it against the server’s ulimit -n, not a round number from a tutorial.

Pick your proxy_pass target

  • http://127.0.0.1:3000 — simplest, most portable.
  • http://unix:/run/myapp.sock — a Unix socket, faster loopback and no TCP port to guard. Slightly more config on the app side, and you must fix permissions between the app user and Nginx.

The loopback TCP form is the right default. Switch to a socket only when you’re chasing the last few milliseconds.

The short version

Nginx as a reverse proxy is thirty lines and a handful of decisions. Forward Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto; force HTTP/1.1 and the upgrade headers for WebSockets; mind the trailing slash on proxy_pass; set a sane client_max_body_size; and let Certbot handle TLS. That covers the vast majority of “it worked locally but the server is broken” cases.

Sources

  1. Certbot — Using Certbot (authenticators vs installers; the nginx plugin modifies server config). https://eff-certbot.readthedocs.io/en/stable/using.html — checked 2026-09-27
  2. nginx — Core functionality (worker_connections, default 512). https://nginx.org/en/docs/ngx_core_module.html — checked 2026-09-27