Deploying Express Apps
Shipping an Express app to production means more than copying files to a server and running node. You need to choose a hosting model that matches your scale and budget, flip Node into production mode, put a process manager or platform in front of your code, and verify a handful of hardening steps before the first real request arrives. This page surveys the main deployment targets, explains the NODE_ENV=production switch, and gives you a concrete pre-deploy checklist.
Choosing a deployment target
Express is “just” a Node.js process, so it runs almost anywhere that runs Node. The trade-off is always the same: how much infrastructure do you want to manage versus how much control and cost predictability you need.
| Target | What you manage | Best for | Scaling model |
|---|---|---|---|
| VPS (DigitalOcean, Linode, EC2) | OS, Node, process manager, proxy | Full control, predictable cost | Manual / vertical, multi-process |
| PaaS (Render, Railway, Fly.io, Heroku) | App code + config only | Fast shipping, small teams | Push-to-deploy, horizontal |
| Containers (Docker + ECS, Kubernetes, Cloud Run) | Image + orchestration config | Reproducible builds, microservices | Declarative, autoscaling |
| Serverless (AWS Lambda, Vercel, Cloudflare) | Handler code only | Spiky traffic, low idle cost | Per-request, scale-to-zero |
On a VPS you install Node, run your app under a process manager like PM2, and place Nginx in front for TLS and load balancing. A PaaS removes the OS layer: you push a Git repo and the platform builds and runs it. Containers package your app and its dependencies into an immutable image (see Docker for Express) that runs identically on a laptop, CI, or a cluster. Serverless wraps your Express app so each HTTP request invokes a function — great for bursty workloads, with the caveat of cold starts and statelessness (see serverless Express).
The NODE_ENV=production switch
NODE_ENV is an environment variable that Express and many libraries read to decide how to behave. Setting it to production is the single highest-impact thing you can do for a deployed app: Express caches view templates, skips verbose error stack traces in responses, and enables internal optimizations. Many middleware packages (and Node itself, in some build steps) also tune themselves based on this flag.
# Set it in the shell / process manager / platform config — never hardcode it
export NODE_ENV=production
node server.js
Read it in your app to gate behavior such as error detail and logging verbosity.
import express from "express";
const app = express();
const isProd = process.env.NODE_ENV === "production";
app.get("/health", (req, res) => {
res.json({ status: "ok", env: process.env.NODE_ENV });
});
// Centralized error handler — leak details only outside production
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({
error: isProd ? "Internal Server Error" : err.message,
...(isProd ? {} : { stack: err.stack }),
});
});
app.listen(process.env.PORT || 3000);
A request to the health endpoint confirms the active environment.
Output:
GET /health
{ "status": "ok", "env": "production" }
Tip: Run
npm ci --omit=dev(ornpm install --production) when building for production so devDependencies never ship. In Express 5.x the error-handling contract is unchanged, but note that 5.x requires Node.js 18+ and automatically forwards rejected promises from async handlers to your error middleware — so you no longer need manualtry/catchjust to callnext(err).
Reading configuration safely
Production apps should take secrets and tunable values from the environment, not from committed files. Validate them at startup so the process fails fast instead of misbehaving under load. See environment configuration for the full pattern.
const required = ["DATABASE_URL", "SESSION_SECRET"];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
console.error(`Missing required env vars: ${missing.join(", ")}`);
process.exit(1);
}
Pre-deploy checklist
Walk this list before every production release. Most outages on day one trace back to a skipped item here.
- Trust the proxy. Behind Nginx or a PaaS load balancer, call
app.set("trust proxy", 1)soreq.ip,req.protocol, and secure cookies reflect the original client. - Bind the right port and host. Read the port from
process.env.PORT; platforms inject it. Bind to0.0.0.0in containers, not127.0.0.1. - Add security headers. Use
helmet()and lock down CORS to known origins. - Compress responses with
compression()(or let your proxy/CDN handle it). - Externalize secrets. No credentials in the repo; validate required env vars at boot.
- Handle shutdown cleanly. Listen for
SIGTERMand drain connections (see graceful shutdown). - Run a real reverse proxy or TLS terminator. Do not expose raw Node on port 80/443 to the internet.
A minimal production-ready bootstrap pulls several of these together.
import express from "express";
import helmet from "helmet";
import compression from "compression";
const app = express();
app.set("trust proxy", 1);
app.use(helmet());
app.use(compression());
app.use(express.json());
app.get("/", (req, res) => res.send("ok"));
const server = app.listen(process.env.PORT || 3000, "0.0.0.0", () => {
console.log(`Listening on ${server.address().port}`);
});
Output:
Listening on 3000
Best Practices
- Set
NODE_ENV=productionthrough your platform or process manager, never inside source code orpackage.jsonscripts that also run locally. - Pin Node and dependency versions (lockfile +
enginesfield) so every environment runs identical code. - Run multiple instances — PM2 cluster mode, container replicas, or PaaS dynos — to use all CPU cores and survive a crash.
- Terminate TLS at a reverse proxy or platform edge and forward plain HTTP to Express on a private network.
- Centralize logging and ship logs off the host; structured JSON logs are easiest to query in production.
- Always implement graceful shutdown so deploys and autoscaling events don’t drop in-flight requests.
- Add a lightweight
/healthendpoint so load balancers and orchestrators can route traffic only to ready instances.