NodeJSReverse Proxy with Nginx

Reverse Proxy with Nginx

Node should not be exposed directly to the internet. A reverse proxy sits in front and handles the work Node isn't good at: TLS termination (your certificate lives at Nginx, Node speaks plain HTTP internally), serving static files at OS speeds without touching Node, request buffering (Nginx absorbs slow clients so your Node workers are freed instantly), gzip compression, rate limiting, and routing traffic across multiple backend instances. Nginx is the most widely used reverse proxy for Node, and once you understand its configuration model — server blocks, location blocks, the proxy_pass directive — the rest follows.

Why Node needs a proxy in front

Concern

Node alone

With Nginx in front

TLS / HTTPS

Manageable but complex

Nginx terminates; Node handles plain HTTP internally

Slow clients

Worker thread held until client finishes receiving

Nginx buffers; Node worker freed immediately

Static files

Served via Node (slow, ties up event loop)

Served directly from disk at full OS speed

Port 80/443

Requires root or CAP_NET_BIND_SERVICE

Nginx binds; Node runs on an unprivileged port

Multiple apps

Can't share port 80/443

Nginx routes by domain/path to any backend

Nginx handles TLS, buffering, and static files — Node only sees fully-received, buffered requests on a plain HTTP socket
Node is excellent at async I/O but not designed as a front-line HTTP server. **Slow clients** — mobile users on weak connections — would hold a Node worker connection open until they finish receiving the full response; Nginx buffers the response and lets the Node worker go immediately. **TLS** is achievable in Node but Nginx handles it more efficiently and keeps cert management (Let's Encrypt / Certbot integration) in one place outside your app code. **Static files** served through Node go through the JavaScript event loop; Nginx serves them directly from the filesystem — orders of magnitude faster. And running on **ports 80/443** without root is straightforward when Nginx owns those ports and proxies back to Node on a high port. The combined stack is: the internet → Nginx on 443 → Node on 3000.
A minimal proxy config

/etc/nginx/sites-available/myapp

Text
server {
    listen 80;
    server_name api.example.com;
    return 301 https://$host$request_uri;   # redirect all HTTP → HTTPS
}

server {
    listen 443 ssl;
    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;

    location / {
        proxy_pass         http://localhost:3000;   # forward to Node
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;  # needed for WebSocket upgrades
        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;
    }
}
Set `X-Forwarded-For` and `X-Forwarded-Proto`, and trust them in Express — otherwise `req.ip` and `req.secure` are always wrong
When Nginx proxies to Node, requests arrive from `127.0.0.1` (the proxy) rather than the real client, so `req.ip` in Express will always be `127.0.0.1` — useless for logging or rate-limiting. Nginx solves this by forwarding the real IP in **`X-Forwarded-For`** and the original scheme in **`X-Forwarded-Proto`**. But Express must be told to **trust these headers** (`app.set('trust proxy', 1)`) before it reads them into `req.ip` and `req.secure` — without that setting, `req.secure` will be `false` even on an HTTPS connection, breaking redirects and secure-cookie flags. Set the headers in Nginx, trust the proxy in Express, and verify `req.ip` logs the actual client address. Also include the `Upgrade`/`Connection` headers if you serve [WebSockets](/nodejs/websockets) — without them the upgrade handshake fails.
Static files and gzip

Text
server {
    # ...

    # Serve static files directly from disk — never hit Node:
    location /static/ {
        root /var/www/myapp;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Compress text responses before sending to the client:
    gzip on;
    gzip_types text/plain application/json application/javascript text/css;
    gzip_min_length 1024;

    location / {
        proxy_pass http://localhost:3000;
    }
}
Serve static assets and enable gzip at the Nginx layer — Node doesn't need to touch either
Two easy wins at the Nginx layer. **Static files**: a `location /static/` block with `root` serving files directly from disk, with aggressive cache headers (`Cache-Control: public, immutable` + `expires 1y`) for assets with content-hashed names — Nginx streams them at full I/O speed, Node never wakes up. **Gzip**: `gzip on` with the relevant MIME types compresses text responses (JSON, HTML, JS, CSS) before they leave the server, often cutting payload size by 70–80%. Neither requires any change to your Node code, and both dramatically improve performance and reduce bandwidth. (If you're behind a CDN that handles compression, don't double-gzip — check what the CDN already does.) Note: don't compress already-compressed formats like JPEG/PNG/WOFF2 — you waste CPU for no gain, or bloat them.
Load balancing across multiple Node instances

Text
# If running separate processes (e.g. on multiple servers) rather than cluster mode:
upstream node_backend {
    least_conn;                        # route to instance with fewest active connections
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
    keepalive 32;                      # keep connections to upstreams warm
}

server {
    location / {
        proxy_pass http://node_backend;
    }
}
An `upstream` block load-balances across multiple Node instances — use `least_conn` for connection-heavy apps
When you run separate Node processes (rather than PM2 cluster mode on one machine), the Nginx **`upstream`** block distributes traffic across them. `least_conn` routes each request to the backend with the fewest active connections, which is fairer than the default round-robin for requests that vary widely in duration. `keepalive` maintains a pool of persistent connections to the backends, avoiding per-request TCP handshake overhead. This setup also enables **health checking** (mark an upstream `down` to take it out of rotation) and is how you'd front multiple servers in a truly horizontal scaling setup. In most single-VM deployments, [PM2](/nodejs/pm2) cluster mode + one Nginx proxy is simpler; the `upstream` block is for cross-machine or cross-container scenarios.
Next
Package your app for consistent deployment anywhere: [Containerizing with Docker](/nodejs/docker).