Node.js · Production

Deployment

Running node server.js on your laptop is not production. Production needs a process manager or container, a reverse proxy with HTTPS, configuration from the environment, health checks and safe restarts.

Advanced 6 min Lesson 35 of 37Examples target Node.js 22 LTS.
0/37 in Node.js
On this page · 6 min · Advanced

Learning objectives#

  • Prepare a Node app for production
  • Run it with PM2 or Docker
  • Put Nginx in front with HTTPS
  • Add health checks and graceful shutdown

Explanation#

Install exactly what is locked: npm ci --omit=dev. Set NODE_ENV=production (Express and libraries enable optimisations) and load config from environment variables.

A process manager restarts the app if it crashes and on server reboot: PM2 (pm2 start ecosystem.config.cjs, pm2 save, pm2 startup) or systemd. In containers, Docker/Kubernetes do this job.

Run Node behind a reverse proxy (Nginx, Caddy, a cloud load balancer) that terminates HTTPS, serves static files, compresses and forwards to localhost:3000. Set app.set("trust proxy", 1) so req.ip and secure cookies work.

Add GET /health (and /ready that checks the database) for load balancers and orchestrators.

Handle SIGTERM: stop accepting connections, finish in-flight requests, close the database pool, then exit — so deploys and scaling never cut off users.

Platforms: a VPS (Hostinger VPS, DigitalOcean) with PM2 + Nginx, containers on AWS ECS / Azure Container Apps, or PaaS like Azure App Service and Render.

Syntax#

Shell Illustrative
npm ci --omit=dev
NODE_ENV=production node --env-file=.env server.js


# or with PM2
pm2 start server.js --name shop-api -i max
pm2 save && pm2 startup

Code example: A production-ready server entry point#

JavaScriptserver.js Illustrative
import express from 'express';


const config = {
  port: Number(process.env.PORT ?? 3028),
  env: process.env.NODE_ENV ?? 'development',
};


const db = {                                     // stand-in for a MySQL pool
  ok: true,
  async ping() { return this.ok; },
  async end() { console.log('database pool closed'); },
};


const app = express();
app.set('trust proxy', 1);                       // behind Nginx / a load balancer
app.get('/health', (req, res) => res.json({ status: 'ok', uptime: Math.round(process.uptime()) >= 0 }));
app.get('/ready', async (req, res) => ((await db.ping()) ? res.json({ ready: true }) : res.status(503).json({ ready: false })));
app.get('/api/slow', async (req, res) => { await new Promise(r => setTimeout(r, 300)); res.json({ done: true }); });


const server = app.listen(config.port, () => console.log(`listening on ${config.port} (${config.env})`));


let shuttingDown = false;
async function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`${signal} received: refusing new connections, finishing current requests`);
  server.close(async () => {
    await db.end();
    console.log('shutdown complete');
    process.exit(0);
  });
  setTimeout(() => { console.error('forced exit after 10 s'); process.exit(1); }, 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));


// Demo: health checks, then a deploy arrives while a slow request is running
const base = `http://localhost:${config.port}`;
console.log(await (await fetch(base + '/health')).text());
console.log(await (await fetch(base + '/ready')).text());
const inFlight = fetch(base + '/api/slow').then(r => r.text());
setTimeout(() => process.emit('SIGTERM'), 50);
console.log('in-flight request finished with:', await inFlight);
Expected output
listening on 3028 (development)
{"status":"ok","uptime":true}
{"ready":true}
SIGTERM received: refusing new connections, finishing current requests
in-flight request finished with: {"done":true}
database pool closed
shutdown complete

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. Configuration comes from environment variables with safe defaults.
  2. trust proxy makes req.ip and secure cookies work behind a reverse proxy.
  3. /health says the process is alive; /ready also checks dependencies.
  4. On SIGTERM, server.close stops new connections but lets the slow request finish.
  5. After the last request, the database pool closes and the process exits; a 10-second timer forces exit if something hangs.

Real-world use case#

A Node.js API on a Hostinger VPS runs under PM2 in cluster mode behind Nginx with a Let’s Encrypt certificate. Deploys run git pull && npm ci && pm2 reload, and PM2’s graceful reload uses exactly this SIGTERM handling for zero downtime.

Common mistakes#

Running as root on port 80

Run Node as an unprivileged user on a high port and let Nginx handle 80/443.

node server.js in a terminal

It stops when you log out or the app crashes. Use PM2, systemd or containers.

No graceful shutdown

Every deploy drops in-flight requests and can leave transactions half-done.

Best practices#

  • npm ci --omit=dev and NODE_ENV=production
  • Process manager or container orchestrator
  • Reverse proxy with HTTPS
  • Health/readiness endpoints and graceful shutdown

Remember this#

npm ci → NODE_ENV=production → PM2/Docker → Nginx/HTTPS.

/health = alive. /ready = dependencies OK.

SIGTERM: close server → finish requests → close DB → exit.

Practice exercise#

Your task: Write an Nginx server block that redirects HTTP to HTTPS and proxies https://api.shop.example.com to a Node app on localhost:3000.

Need a hint?
Two server blocks: listen 80 with return 301, and listen 443 ssl with proxy_pass.
Show solution
Text Illustrative
server {
    listen 80;
    server_name api.shop.example.com;
    return 301 https://$host$request_uri;
}


server {
    listen 443 ssl http2;
    server_name api.shop.example.com;
    ssl_certificate     /etc/letsencrypt/live/api.shop.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.shop.example.com/privkey.pem;


    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Mini quiz#

Deployment quizMultiple choice Question 1 of 3

Which command installs production dependencies exactly as locked?

Score so far: 0/0

Want more? Practise all Node.js MCQs · Node.js interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.