Deploying Node (PM2 & Docker)

Reviewed & published by Brayan K

Deploying a Node app means running it reliably in production: loading config from environment variables, keeping the process alive with a manager like PM2 or a container, exposing a health check, and shutting down gracefully on SIGTERM.

Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll set NODE_ENV, load config safely from env vars, supervise your app with PM2 (cluster, logs, restart, reload), containerize it with a cache-friendly Dockerfile and health check, and handle graceful shutdown so deploys never drop a request.

What You'll Learn in This Lesson

1️⃣ Environment & Config

Production code should never hard-code secrets or settings. Instead it reads environment variables — process.env.PORT, process.env.DATABASE_URL, process.env.NODE_ENV — so the same image runs in dev, staging, and prod with different values. A good config loader applies defaults and fails fast: if a required variable is missing, it crashes at startup, not later under load.

// A tiny config loader: read env vars, apply defaults, fail fast on missing ones.
// This runs as plain Node — set vars with NODE_ENV=production node app.js
function loadConfig(env) {
  const required = ['DATABASE_URL'];
  for (const key of required) {
    if (!env[key]) throw new Error(`Missing required env var: ${key}`);
  }
  return {
    nodeEnv: env.NODE_ENV || 'development',
    port: Number(env.PORT || 3000),
    databaseUrl: env.DATABASE_URL,
    isProd: env.NODE_ENV === 'production',
  };
}

// Simulate a production environment object.
const config = loadConfig({ NODE_ENV: 'production', PORT: '8080', DATABASE_URL: 'postgres://db/app' });
console.log('Env:    ', config.nodeEnv);
console.log('Port:   ', config.port);
console.log('Is prod?', config.isProd);

// Missing a required var? Crash NOW, at startup — not later under load.
try {
  loadConfig({ NODE_ENV: 'production' });   // no DATABASE_URL
} catch (err) {
  console.log('Startup failed ->', err.message);
}

2️⃣ Keeping It Alive: PM2 & Graceful Shutdown

A plain node app.js stops the instant it crashes or the box reboots. PM2 is a process manager that supervises your app: it restarts on crash, runs one worker per CPU core in cluster mode, aggregates logs, and can reload with zero downtime. These are shell commands you run on the server:

When PM2 (or Docker) stops your app it sends SIGTERM first. Catch it and shut down gracefully: stop accepting new connections, let in-flight requests finish, then exit. The block below runs for real and sends itself SIGTERM so you can see the sequence:

const http = require('node:http');
const server = http.createServer((req, res) => res.end('ok'));

server.listen(3000, () => console.log('Server listening on port 3000'));

// On SIGTERM (what PM2, Docker, and Kubernetes send to stop you), stop
// accepting NEW connections, finish in-flight ones, then exit cleanly.
// Without this, the platform force-kills you and drops live requests.
function shutdown(signal) {
  console.log(`Received ${signal}, shutting down gracefully...`);
  server.close(() => {
    console.log('Closed remaining connections. Bye.');
    process.exit(0);
  });
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

// (Demo: we send SIGTERM to ourselves so the output is reproducible.)
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 100);

3️⃣ Containerizing with Docker + Health Checks

Docker packages your app and its exact Node version into an image that runs identically anywhere. The key trick is layer caching: copy package*.json and run npm ci before copying your source, so the slow install step is cached when only your code changes.

# A small, production-ready Dockerfile for a Node app.
FROM node:22-alpine

# Run as a non-root user for safety.
WORKDIR /app

# Copy manifests FIRST so Docker can cache the slow "npm ci" layer.
COPY package*.json ./
RUN npm ci --omit=dev

# Now copy the rest of the source.
COPY . .

# Tell Docker (and humans) which port the app uses.
ENV NODE_ENV=production
EXPOSE 3000

# A health check lets the orchestrator know if the app is alive.
HEALTHCHECK --interval=30s --timeout=3s \
  CMD node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"

# Use the array form so signals (SIGTERM) reach Node for graceful shutdown.
CMD ["node", "app.js"]

The Dockerfile's HEALTHCHECK pings a /health route so the orchestrator knows whether this instance should receive traffic. That route is just a cheap endpoint in your app:

const express = require('express');
const app = express();

// A health endpoint the load balancer / orchestrator pings to decide if
// this instance should receive traffic. Keep it cheap and dependency-light.
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok', uptime: process.uptime() });
});

app.listen(process.env.PORT || 3000);

Your turn. Fill in the two ___ blanks so the config helpers read the port and mode correctly, then run it.

// 🎯 YOUR TURN — replace each ___ then run it.
// Read a PORT from the environment, defaulting to 3000, and report the mode.

function getPort(env) {
  return Number(env.PORT || ___);          // 👉 3000  (the default port)
}
function isProduction(env) {
  return env.NODE_ENV === ___;             // 👉 'production'
}

const env = { PORT: '8080', NODE_ENV: 'production' };
console.log('Port:', getPort(env));
console.log('Production?', isProduction(env));

// ✅ Expected output:
//    Port: 8080
//    Production? true

Mini-Challenge: A Server That Stops Cleanly

No blanks this time — just a brief and an outline. Write it yourself, run it, and check your output. This is the exact pattern that keeps deploys from dropping live requests.

const http = require('node:http');

// 🎯 MINI-CHALLENGE: a server that shuts down gracefully.
// 1. Create an http server that replies 'ok' to every request.
// 2. Listen on port 3000 and log 'up'.
// 3. On 'SIGTERM', log 'stopping', then server.close() and log 'done' + exit(0).
// 4. (Demo line is provided to fire SIGTERM at yourself after 100ms.)
//
// ✅ Example output:
//    up
//    stopping
//    done

// your code here

// keep this demo line LAST so it triggers your handler:
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 100);

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Deployment

TaskCommand / Syntax
Set prod modeNODE_ENV=production node app.js
Start with PM2pm2 start app.js --name api
Cluster modepm2 start app.js -i max
Zero-downtimepm2 reload api
Build imagedocker build -t api .
Graceful stopprocess.on('SIGTERM', ...)
Health routeapp.get('/health', ...)

🎉 Lesson Complete!

Practice quiz

Why should production code read settings from environment variables?

  • It makes the code run faster
  • The same image can run in dev, staging, and prod with different values and no hard-coded secrets
  • Environment variables are encrypted automatically
  • It removes the need for a database

Answer: The same image can run in dev, staging, and prod with different values and no hard-coded secrets. Reading from env vars keeps secrets out of code and lets one image run anywhere with different values.

What does a 'fail fast' config loader do when a required variable is missing?

  • Substitutes a random default
  • Logs a warning and keeps running
  • Crashes at startup instead of later under load
  • Retries every few seconds forever

Answer: Crashes at startup instead of later under load. Failing fast means crashing at boot so a misconfigured deploy is obvious immediately, not later.

What does setting NODE_ENV=production do?

  • It encrypts all responses
  • It signals Express and many libraries to switch into optimized production behavior
  • It opens a new port automatically
  • It disables the database

Answer: It signals Express and many libraries to switch into optimized production behavior. NODE_ENV=production tells Express and libraries to cache views, skip verbose errors, and optimize.

What is the main job of a process manager like PM2?

  • It compiles your TypeScript
  • It writes your Dockerfile
  • It minifies your JavaScript
  • It keeps your app alive, restarting it on crash and after reboots

Answer: It keeps your app alive, restarting it on crash and after reboots. PM2 supervises the app: restart on crash, cluster mode, log handling, and relaunch after reboot.

Which signal do PM2, Docker, and Kubernetes send to ask your app to stop?

  • SIGTERM
  • SIGKILL
  • SIGHUP
  • SIGUSR1

Answer: SIGTERM. They send SIGTERM first; catch it to shut down gracefully before the platform force-kills with SIGKILL.

What does a graceful SIGTERM handler call to stop accepting new connections while finishing in-flight ones?

  • process.exit() immediately
  • server.close()
  • app.disable()
  • process.kill()

Answer: server.close(). server.close() stops new connections but lets existing requests finish, then you exit cleanly.

Why copy package*.json and run npm ci BEFORE copying the rest of the source in a Dockerfile?

  • To make the image larger
  • So the slow install layer is cached and reused when only your code changes
  • Because npm requires it
  • To hide your source code

Answer: So the slow install layer is cached and reused when only your code changes. Copying manifests first lets Docker cache the expensive npm ci layer across builds where deps didn't change.

Why use the array form CMD ["node", "app.js"] instead of the string form in a Dockerfile?

  • It is shorter to type
  • It runs the app twice for safety
  • So Node runs as PID 1 and receives SIGTERM directly for graceful shutdown
  • It enables cluster mode

Answer: So Node runs as PID 1 and receives SIGTERM directly for graceful shutdown. The exec/array form makes Node PID 1 so SIGTERM reaches it; the string form wraps it in a shell that may swallow the signal.

What is the purpose of a /health endpoint and the Dockerfile HEALTHCHECK?

  • To return server logs
  • To restart the database
  • To serve the homepage
  • To let the orchestrator know whether the instance should receive traffic

Answer: To let the orchestrator know whether the instance should receive traffic. A cheap /health route lets the load balancer or orchestrator decide if the instance is alive and ready.

Which PM2 command reloads with zero downtime by draining old workers first?

  • pm2 reload api
  • pm2 restart api
  • pm2 stop api
  • pm2 kill api

Answer: pm2 reload api. pm2 reload drains old workers before swapping in new ones, giving zero-downtime deploys.

Continue this course

Frequently asked questions

Why does setting NODE_ENV=production matter?

NODE_ENV is the conventional switch that tells your app and its libraries they're in production. Express, for example, caches view templates and skips verbose error pages when NODE_ENV is 'production', and many packages disable debug output or enable optimizations based on it. It also signals npm ci --omit=dev to skip dev dependencies. It is not magic — it only changes behavior in code that reads it — but the ecosystem has standardized on it, so always set it explicitly in production rather than relying on a default.

What does a process manager like PM2 actually do for me?

A bare 'node app.js' dies the moment it crashes or the server reboots, taking your site down. PM2 is a process manager that keeps the app alive: it restarts it automatically if it crashes, can run one instance per CPU core in cluster mode to use all your cores, aggregates and rotates logs, supports zero-downtime reloads, and (with pm2 startup + pm2 save) relaunches everything after a reboot. It turns a fragile foreground process into a supervised, resilient service. In a containerized setup the orchestrator (Docker/Kubernetes) plays a similar supervising role.

Why is graceful shutdown on SIGTERM important?

When a platform stops your app — a deploy, a scale-down, a container restart — it sends SIGTERM and then, after a grace period, SIGKILL. If you ignore SIGTERM, in-flight requests are cut off mid-response and connections to your database may not close cleanly. A graceful handler calls server.close() so the process stops accepting new connections but lets existing requests finish, closes database pools, and then exits 0. The result is zero dropped requests during deploys and restarts, which is essential for a reliable production service.

Why copy package.json before the rest of the code in a Dockerfile?

Docker builds images in layers and caches each one. Dependency installation (npm ci) is the slow step, but your dependencies change far less often than your source code. By copying only package.json and package-lock.json first and running npm ci, that expensive layer is cached and reused on every build where the manifests didn't change. If you copied all your source first, any one-line code change would invalidate the cache and force a full reinstall every build. Copying manifests first, then the source, gives you fast, cache-friendly builds.