Deploying behind a proxy

Express · Advanced · 15 min

Misconfigured proxies cause wrong client IPs, broken redirects and insecure cookies; sloppy shutdowns drop requests on every deploy.

In production Express almost always sits behind a reverse proxy or load balancer (nginx, a cloud LB, Kubernetes ingress) that terminates TLS and forwards plain HTTP. From Express's point of view every request then comes from the proxy, and the real client details travel in X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host.

Express ignores those headers until you set `trust proxy`, because a client can forge them. Configure it to match your topology exactly:

  • app.set("trust proxy", 1) — trust one hop (a single load balancer). Most common.
  • A number of hops, specific IPs/subnets, or "loopback" / "uniquelocal".
  • Avoid true (trust everything) unless you are certain all traffic passes your proxy; otherwise clients can spoof their IP.

With it set correctly, req.ip is the client, req.protocol is https, req.secure is true, so secure cookies get set and rate limiters key on real IPs.

Other production essentials:

  • Listen on process.env.PORT, bound to all interfaces in containers.
  • NODE_ENV=production, dependencies installed with npm ci --omit=dev.
  • Run node server.js directly as the container command, or under systemd; not npm start, which may not forward signals.
  • Scale with multiple instances behind the load balancer; keep sessions and caches in shared stores.
  • Graceful shutdown: on SIGTERM, fail readiness, call server.close() to stop accepting connections and let in-flight requests finish, close database pools, then exit, with a timeout as a backstop.
  • Align timeouts: Node's server.keepAliveTimeout should exceed the load balancer's idle timeout, otherwise the LB may reuse a connection Node just closed and return sporadic 502s.

Example

// server.js — run as: NODE_ENV=production node server.js
import express from "express";

const app = express();
app.set("trust proxy", 1);                        // exactly one proxy in front of us
let ready = true;

app.get("/readyz", (req, res) => res.sendStatus(ready ? 200 : 503));
app.get("/whoami", (req, res) => res.json({ ip: req.ip, protocol: req.protocol, secure: req.secure }));

const server = app.listen(Number(process.env.PORT ?? 3000), (err) => {
  if (err) throw err;
  console.log("listening", server.address());
});
server.keepAliveTimeout = 65_000;                 // > load balancer idle timeout (e.g. 60 s)
server.headersTimeout = 66_000;

process.on("SIGTERM", () => {
  ready = false;                                  // LB stops sending new traffic
  setTimeout(() => {
    server.close(async () => {                    // waits for in-flight requests
      // await db.end(); await redis.quit();
      process.exit(0);
    });
  }, 5_000).unref();
  setTimeout(() => process.exit(1), 30_000).unref();   // hard deadline
});

Remember

  • Set trust proxy to your real topology (often 1), never blindly true
  • Then req.ip, req.protocol and secure cookies work behind TLS termination
  • Run node directly, NODE_ENV=production, npm ci --omit=dev
  • Graceful shutdown on SIGTERM; keepAliveTimeout above the LB's idle timeout

Watch out for

  • Leaving trust proxy unset so every request appears to come from the load balancer's IP.
  • Setting trust proxy to true on a directly exposed server, letting clients spoof X-Forwarded-For.

Check yourself

  1. Behind an HTTPS load balancer, req.protocol reports "http" and secure cookies aren't set. What's the fix?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/guide/behind-proxies