Skip to content
Express.js ex deployment 5 min read

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.

TargetWhat you manageBest forScaling model
VPS (DigitalOcean, Linode, EC2)OS, Node, process manager, proxyFull control, predictable costManual / vertical, multi-process
PaaS (Render, Railway, Fly.io, Heroku)App code + config onlyFast shipping, small teamsPush-to-deploy, horizontal
Containers (Docker + ECS, Kubernetes, Cloud Run)Image + orchestration configReproducible builds, microservicesDeclarative, autoscaling
Serverless (AWS Lambda, Vercel, Cloudflare)Handler code onlySpiky traffic, low idle costPer-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 (or npm 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 manual try/catch just to call next(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) so req.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 to 0.0.0.0 in containers, not 127.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 SIGTERM and 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=production through your platform or process manager, never inside source code or package.json scripts that also run locally.
  • Pin Node and dependency versions (lockfile + engines field) 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 /health endpoint so load balancers and orchestrators can route traffic only to ready instances.
Last updated June 14, 2026
Was this helpful?