Deploying a Node.js Application

Writing a Node.js application is only half the job — at some point it has to run reliably on a server, restart itself when it crashes, use environment-specific configuration, and survive being redeployed without dropping requests. “Deployment” is the set of practices that take a script you run with node app.js on your laptop and turn it into a production service. This lesson covers the pieces every Node deployment needs: environment configuration, process managers, containerization, multi-core scaling, and graceful shutdown.

Overview: What Changes Between Dev and Production

When you run node app.js locally, a single OS process runs your event loop. If it throws an uncaught exception, it dies, and you restart it by hand. If you edit a file, you restart it again. None of that is acceptable in production, where a crashed process means downtime and nobody is watching the terminal.

A production deployment adds three things Node itself does not provide out of the box:

  • Process supervision — something that starts your app, restarts it if it exits, and can run several copies across CPU cores. This is a process manager (PM2), a container orchestrator (Docker/Kubernetes), or an OS-level supervisor (systemd).
  • Environment-based configuration — the same code needs a different port, database URL, and log level in development vs. production, read from process.env instead of hardcoded.
  • Lifecycle handling — your app needs to respond correctly to the signals the platform sends it: exit cleanly on SIGTERM when the platform wants to redeploy or scale down, and expose a health check endpoint so the platform knows when it is actually ready to receive traffic.

Node itself is single-threaded for JavaScript execution — one process uses one CPU core. Production deployments almost always run multiple Node processes (via the node:cluster module, PM2’s cluster mode, or multiple containers behind a load balancer) to use all the cores on a machine and to survive a single process crashing.

Preparing an Application for Production

Before touching a process manager or Docker, the application code itself needs to follow a few conventions:

  • Read the port and all configuration from process.env, with sensible defaults for local development: process.env.PORT ?? 3000.
  • Never commit secrets. Keep them in environment variables supplied by the platform, or in a local .env file loaded with node --env-file=.env (Node 20.6+) or the dotenv package, and add .env to .gitignore.
  • Log to stdout/stderr with console.log/console.error rather than writing to local log files — process managers, Docker, and cloud platforms all capture standard streams and forward them to centralized logging.
  • Expose a lightweight health check route (commonly /healthz) that returns 200 as soon as the server can accept traffic, so load balancers and orchestrators know when to route requests to it.
  • Handle SIGTERM to close the HTTP server and any open database connections before the process actually exits.

Examples

Example 1: A Production-Ready HTTP Server

This server reads its port from the environment, exposes a health check, and shuts down gracefully when the process manager or container runtime sends SIGTERM.

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

const PORT = process.env.PORT ?? 3000;
const NODE_ENV = process.env.NODE_ENV ?? "development";

const server = http.createServer((req, res) => {
  if (req.url === "/healthz") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "ok", env: NODE_ENV }));
    return;
  }

  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Hello from a production Node.js server\n");
});

server.listen(PORT, () => {
  console.log(`Server listening on port ${PORT} in ${NODE_ENV} mode`);
});

function shutdown(signal) {
  console.log(`Received ${signal}, closing server gracefully...`);
  server.close((err) => {
    if (err) {
      console.error("Error during shutdown:", err);
      process.exit(1);
    }
    console.log("Server closed. Exiting.");
    process.exit(0);
  });

  setTimeout(() => {
    console.error("Forced shutdown after timeout");
    process.exit(1);
  }, 10_000).unref();
}

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

Output:

Server listening on port 3000 in development mode

server.close() stops accepting new connections and calls its callback once all in-flight requests finish, which is why it belongs in the shutdown handler rather than a bare process.exit(). The 10-second timer is a safety net: if some connection never finishes (a stuck upload, a hung keep-alive socket), the process still exits instead of hanging forever and blocking a redeploy. Calling .unref() on that timer means it won’t itself keep the process alive if shutdown finishes first.

Example 2: Running Under PM2

PM2 is the most widely used Node.js process manager. It restarts your app if it crashes, can run it in cluster mode across every CPU core, and keeps it running across server reboots.

{
  "name": "my-app",
  "version": "1.0.0",
  "private": true,
  "main": "server.js",
  "type": "commonjs",
  "engines": {
    "node": ">=20"
  },
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "dependencies": {
    "express": "^5.0.0"
  }
}
module.exports = {
  apps: [
    {
      name: "my-app",
      script: "server.js",
      instances: "max",
      exec_mode: "cluster",
      env: {
        NODE_ENV: "production",
        PORT: 3000
      }
    }
  ]
};
npm install pm2 --save
npx pm2 start ecosystem.config.js
npx pm2 status
npx pm2 logs my-app
npx pm2 reload my-app

The ecosystem.config.js file tells PM2 to launch as many worker processes as there are CPU cores (instances: "max") in cluster mode, spreading incoming connections across them automatically. pm2 reload restarts those workers one at a time instead of all at once, so the app keeps serving traffic during a deploy — this is called a zero-downtime reload.

Example 3: Containerizing with Docker

Containers package your app together with the exact Node version and OS libraries it needs, so “it works on my machine” stops being a problem. A multi-stage Dockerfile keeps the final image small by not shipping build-time dependencies.

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "server.js"]
docker build -t my-app .
docker run -d -p 3000:3000 --env-file .env --name my-app my-app
docker logs -f my-app
docker stop my-app

npm ci installs exactly what is in package-lock.json (unlike npm install, it never modifies the lockfile), which makes builds reproducible. Running as USER node — a non-root user baked into the official image — means a compromised process inside the container cannot write to most of the filesystem. docker stop sends SIGTERM first and waits (10 seconds by default) before force-killing with SIGKILL, which is exactly what Example 1’s shutdown handler is designed to catch.

Under the Hood: Scaling Across CPU Cores

A single Node process only ever uses one CPU core for running your JavaScript, no matter how much traffic it gets — the event loop is single-threaded even though libuv’s thread pool handles some I/O in the background. To use every core on a machine, you run multiple Node processes. The built-in node:cluster module does this by forking your script into a primary process and several worker processes that all share the same listening port:

const cluster = require("node:cluster");
const http = require("node:http");
const os = require("node:os");

if (cluster.isPrimary) {
  const cpuCount = os.availableParallelism();
  console.log(`Primary ${process.pid} starting ${cpuCount} workers`);

  for (let i = 0; i < cpuCount; i++) {
    cluster.fork();
  }

  cluster.on("exit", (worker, code) => {
    console.log(`Worker ${worker.process.pid} died (code ${code}), restarting...`);
    cluster.fork();
  });
} else {
  http
    .createServer((req, res) => {
      res.writeHead(200);
      res.end(`Handled by worker ${process.pid}\n`);
    })
    .listen(3000);
}

The primary process forks one worker per core (os.availableParallelism(), available in Node 18.4+/19+, returns the number of CPUs the process can actually use). The OS load-balances incoming connections across the workers. If a worker crashes, the exit event fires and the primary forks a replacement, so one bad request doesn’t take down the whole service. PM2’s cluster mode (Example 2) does exactly this under the hood, which is why most teams reach for PM2 or an orchestrator instead of hand-rolling cluster logic.

Common Mistakes

Mistake 1: Blocking the Event Loop in a Request Handler

Synchronous filesystem calls block every other request on that process until they finish. In production this can turn a single slow disk read into a multi-second stall for every concurrent user.

const http = require("node:http");
const fs = require("node:fs");

const server = http.createServer((req, res) => {
  const data = fs.readFileSync("./data/report.json", "utf8");
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(data);
});

server.listen(3000);

Use the promise-based fs/promises API so the read doesn’t block the loop, and wrap it in a try/catch so a missing file returns a proper error response instead of crashing the process:

const http = require("node:http");
const fs = require("node:fs/promises");

const server = http.createServer(async (req, res) => {
  try {
    const data = await fs.readFile("./data/report.json", "utf8");
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(data);
  } catch (err) {
    res.writeHead(500, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ error: "Could not load report" }));
  }
});

server.listen(3000);

Mistake 2: Swallowing Unhandled Rejections and Exceptions

By default, an unhandled promise rejection or uncaught exception can leave your process in an inconsistent state — memory leaks, half-open connections, or a request handler that silently never responds. Just logging it and moving on is worse than crashing, because a process manager can restart a crashed process but has no way to fix a process that is quietly broken.

process.on("unhandledRejection", (reason) => {
  console.log("Unhandled rejection:", reason);
});

Log the error for diagnostics, then exit deliberately so PM2, Docker, or Kubernetes restarts the process into a clean state:

process.on("unhandledRejection", (reason) => {
  console.error("Unhandled rejection:", reason);
  process.exit(1);
});

process.on("uncaughtException", (err) => {
  console.error("Uncaught exception:", err);
  process.exit(1);
});

Mistake 3: Deploying the Development Setup

Running npm run dev (a file-watcher like node --watch, or nodemon) in production wastes memory on filesystem watchers you don’t need and often disables optimizations the platform expects. Likewise, forgetting to set NODE_ENV=production leaves some frameworks (Express notably) running with verbose error pages and disabled view caching. Production start scripts should run the plain node server.js command with NODE_ENV=production set by the platform, never the dev script.

Best Practices

  • Read all configuration — port, database URL, API keys — from process.env; never hardcode values that differ between environments.
  • Keep secrets out of source control entirely; supply them as environment variables from the platform or a .env file that is gitignored.
  • Always handle SIGTERM/SIGINT to close servers and database connections before exiting.
  • Expose a /healthz-style route so load balancers and orchestrators can detect a ready or unhealthy instance.
  • Run multiple instances (via cluster, PM2 cluster mode, or multiple containers) so no single process crash or CPU core limit takes the app down.
  • Log to stdout/stderr and let the platform’s logging system aggregate and store logs, rather than writing to local files.
  • Use npm ci instead of npm install in build pipelines for reproducible, lockfile-exact installs.
  • Run containers as a non-root user and keep base images small (Alpine or slim variants) to reduce attack surface.
  • Set resource limits (memory, CPU) at the orchestrator or container level so one runaway process can’t take down the host.

Practice Exercises

  • Take the server from Example 1 and add a /healthz route that also checks a simulated dependency (for example, a boolean flag) and returns 503 when it’s unhealthy. Configure a process manager to poll it.
  • Write a multi-stage Dockerfile for a small Express app, build the image, run it with docker run, and confirm curl localhost:3000 responds. Then run docker stop and confirm your SIGTERM handler logs a clean shutdown message before the container exits.
  • Convert a single-process HTTP server to use node:cluster so it runs one worker per CPU core, then send load to it and confirm (via logged process.pid values) that requests are distributed across multiple workers.

Summary

  • Production deployments need process supervision, environment-based configuration, and correct signal handling — none of which Node provides automatically.
  • Read configuration from process.env, never hardcode ports or secrets, and keep secrets out of git.
  • Handle SIGTERM/SIGINT by closing the server gracefully with server.close() and exiting only after in-flight requests finish.
  • PM2 and Docker both restart a crashed process automatically and can run multiple instances; PM2’s cluster mode and the built-in node:cluster module let a Node app use every CPU core on the machine.
  • Log to standard streams, expose a health check route, and always handle unhandledRejection/uncaughtException by logging and exiting rather than continuing silently.