Development Tips

Node.js Deployment in 2026: A Practical Setup That Actually Holds Up

By Ken Key Apr 1, 2024 6 min read Updated Oct 16, 2026

Node.js Deployment in 2026: A Practical Setup That Actually Holds Up
The short answer

Deploy Node.js with a real process manager like PM2 in cluster mode, one worker per CPU core, plus waitready and a proper killtimeout for zero-downtime restarts. Skip tmux and nohup. Add health checks and a reverse proxy in front. That boring setup is what keeps apps up in 2026.

Part of the complete web development guide.

On this page

Most Node.js deployment advice you find online was written for a world where you SSH into one box and run node app.js forever. That still works, but it falls apart the first time you need a zero-downtime restart or a real health check. Here’s the deployment setup I actually use and recommend in 2026, along with why each piece is there.

None of this is exotic. It’s just the boring stuff done correctly, which is usually what separates a site that stays up from one that pages you at 2am.

Node.js Deployment in 2026: A Practical Setup That Actually Holds Up

Run a real process manager, not a screen session

The single most common mistake is running your app inside tmux or nohup and calling it deployed. When the process crashes, nothing restarts it. When the server reboots, your app is gone. Use a process manager that handles restarts, clustering, and logs.

PM2 is still the pragmatic default for small and mid-sized apps. It’s not trendy, but it’s reliable and the config is readable.

// ecosystem.config.cjs
module.exports = {
 apps: [{
 name: "myapp",
 script: "./server.js",
 instances: "max", // one worker per CPU core
 exec_mode: "cluster",
 max_memory_restart: "512M",
 env: {
 NODE_ENV: "production",
 PORT: 3000
 },
 env_file: ".env.production",
 kill_timeout: 5000, // give in-flight requests time to finish
 listen_timeout: 10000,
 wait_ready: true // app signals readiness via process.send('ready')
 }]
};

Two details matter here. wait_ready: true means PM2 won’t consider a worker live until your app says so, which prevents routing traffic to a process that hasn’t finished connecting to its database. And kill_timeout gives you a window to drain connections during a restart.

If you’re on a platform like Fly.io, Render, or Railway, their runtime handles this for you. If you’re on a plain VPS (a lot of small businesses still run one box in a colo or a cheap cloud instance), PM2 or systemd is your process manager. Don’t skip it.

Node.js Deployment in 2026: A Practical Setup That Actually Holds Up: Put a reverse proxy in front of it

Put a reverse proxy in front of it

Never expose Node directly on port 80 or 443. Node’s HTTP server is fine for app logic but you don’t want it terminating TLS, serving static files, or handling slow clients. Let nginx do that.

# /etc/nginx/sites-available/myapp
upstream node_app {
 server 127.0.0.1:3000;
 keepalive 64;
}

server {
 listen 443 ssl http2;
 server_name example.com;

 ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
 ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

 location / {
 proxy_pass http://node_app;
 proxy_http_version 1.1;
 proxy_set_header Connection "";
 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;
 proxy_read_timeout 60s;
 }
}

The keepalive 64 plus Connection "" combo is the part people miss. Without it, nginx opens a fresh TCP connection for every request, which adds latency and burns file descriptors under load.

Trust the proxy headers in your app

If you don’t tell Express it’s behind a proxy, req.ip returns 127.0.0.1 for everyone and rate limiting breaks.

import express from "express";
const app = express();

app.set("trust proxy", 1); // one hop: nginx

// now req.ip and req.protocol reflect the real client
app.get("/whoami", (req, res) => {
 res.json({ ip: req.ip, proto: req.protocol });
});

Set it to 1, not true. true trusts every hop in the chain, which means a client can spoof X-Forwarded-For and fool your logs or your rate limiter.

Zero-downtime restarts

With PM2 in cluster mode, pm2 reload myapp restarts workers one at a time instead of all at once. That only works if your app handles the shutdown signal properly. Handle SIGINT and SIGTERM, stop accepting new connections, finish what’s in flight, then exit.

const server = app.listen(process.env.PORT || 3000, () => {
 console.log("listening");
 if (process.send) process.send("ready"); // tell PM2 we're up
});

function shutdown(signal) {
 console.log(`${signal} received, draining`);
 server.close(() => {
 // close DB pools, redis, etc. here
 process.exit(0);
 });
 // hard stop if draining takes too long
 setTimeout(() => process.exit(1), 8000).unref();
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

That setTimeout(...).unref() is the safety net. If a request hangs forever, you don’t want the process to hang with it. The .unref() means the timer itself won’t keep the event loop alive.

Config and secrets

Stop hardcoding connection strings. Twelve-factor was right about this and it’s still right. Use environment variables, and load them explicitly. Node 20+ has built-in --env-file support so you don’t strictly need dotenv anymore:

node --env-file=.env.production server.js

Validate your config at boot and fail loudly if something’s missing. A missing DATABASE_URL should crash the process immediately, not throw a confusing error three requests later.

const required = ["DATABASE_URL", "SESSION_SECRET", "PORT"];
for (const key of required) {
 if (!process.env[key]) {
 console.error(`Missing required env var: ${key}`);
 process.exit(1);
 }
}

Never commit the .env file. Never put secrets in your PM2 config either, since that file usually lives in the repo. Keep secrets in the environment or a secrets manager, and keep the config file referencing them.

Logging and health checks

Two things make production debugging possible: structured logs and a health endpoint that actually checks dependencies.

app.get("/healthz", async (req, res) => {
 try {
 await db.query("SELECT 1");
 res.status(200).json({ status: "ok" });
 } catch (err) {
 res.status(503).json({ status: "degraded" });
 }
});

Point your load balancer or uptime monitor at /healthz, not at /. A 200 from your homepage just means Express is serving HTML. It says nothing about whether your database is reachable.

For logs, use a structured logger like Pino and ship them to stdout. Let the platform or a log shipper collect them. Don’t write log files locally and rotate them by hand unless you enjoy that.

What about containers and serverless?

Docker still makes sense when you want the same environment everywhere. A minimal production Dockerfile for a Node app looks roughly like this:

FROM node:22-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
USER node
EXPOSE 3000
CMD ["node", "server.js"]

Serverless (Lambda, Cloudflare Workers, Vercel functions) is great for spiky or low-traffic workloads, but it changes how you think about connections, cold starts, and state. If your app holds a persistent WebSocket or a long-lived DB pool, a traditional long-running process is often simpler and cheaper.

A quick note on where you host

Where the box sits matters more than people admit. If your customers are mostly in the New York metro, hosting in an East Coast region (us-east-1, or a NY datacenter) cuts real latency versus a West Coast instance. For most local businesses that’s the whole reason to care about region choice at all. If you’re building something custom and want help thinking through the whole stack, this is the kind of thing I handle through custom web development work.

The checklist

  • Process manager with auto-restart and clustering (PM2 or systemd)
  • nginx (or equivalent) terminating TLS and proxying to localhost
  • trust proxy set to the correct hop count
  • Graceful shutdown on SIGTERM with a hard timeout
  • Env vars validated at boot, secrets never in the repo
  • Structured logs to stdout
  • A /healthz endpoint that checks real dependencies
  • Automated TLS renewal (certbot handles this)

Get those eight things right and you’re ahead of most Node deployments I’ve seen. None of it is clever. It’s just done.

FAQ

Do I still need PM2 if I’m using Docker?

Not for restarts, since Docker’s restart policy handles that. But you still want graceful shutdown handling in your app, and inside a container you’d typically run a single process rather than cluster mode, letting the orchestrator scale instead.

Is dotenv obsolete now that Node has --env-file?

For most cases, yes. Node 20+ reads env files natively. You’d still reach for dotenv if you need variable expansion or you’re supporting older Node versions.

How do I do zero-downtime deploys without PM2?

Either run two instances behind a load balancer and swap them, or use a platform that handles rolling deploys for you. The core requirement is the same: your app must drain connections on SIGTERM rather than dying instantly.

Have a project like this?

I do custom web development for Long Island businesses. If you need this done right, let’s talk.