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 withnpm ci --omit=dev.- Run
node server.jsdirectly as the container command, or under systemd; notnpm 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, callserver.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.keepAliveTimeoutshould 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 proxyto your real topology (often1), never blindlytrue - Then
req.ip,req.protocoland 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 proxyunset so every request appears to come from the load balancer's IP. - Setting
trust proxyto true on a directly exposed server, letting clients spoof X-Forwarded-For.