Deployment: PM2, Proxies & Docker

Node.js · Lesson 29 of 30 10 min read Updated

Your app runs on your laptop. Deployment means running it on a server that is always on, reachable from the internet, restarts itself after a crash, uses HTTPS, and can be updated without long downtime. The tools change from company to company, but the ideas are the same everywhere.

In this lesson you will prepare an app for production, keep it running with the PM2 process manager, put the Nginx web server in front of it as a reverse proxy with HTTPS, and package it as a Docker container. The examples assume a Linux server such as Ubuntu 26.04 LTS, which is what most servers run.

Where can a Node.js app run?

OptionYou manageGood for
VPS (virtual private server)Operating system, Node.js, PM2, Nginx, updatesLearning, full control, low monthly cost
Platform as a service (PaaS)Only your code and settings; the platform builds and runs itFast deployment, small teams
Containers (Docker) on a server or a cloud serviceA Dockerfile; the platform runs containersTeams, many services, identical environments

Whichever you choose, the app itself needs the same preparation.

Prepare the app for production

  • Read settings from the environment: port, database URL, secrets. Platforms tell your app which port to use through PORT, so always listen(process.env.PORT ?? 3000).
  • Set NODE_ENV=production. Express then caches view templates and hides error stack traces, and many libraries switch to faster modes.
  • Install only what production needs with npm ci --omit=dev.
  • Log to stdout in JSON (see Logging) and let the platform collect the logs.
  • Add a health check route such as GET /api/health that returns 200, so the platform or load balancer can check the app is alive.
  • Shut down gracefully: on SIGTERM, stop accepting new requests, finish the current ones and close database connections.
JavaScript
// server.js
import { app } from "./src/app.js";

const PORT = Number(process.env.PORT ?? 3000);
const server = app.listen(PORT, () => console.log(`Listening on ${PORT}`));

function shutdown(signal) {
  console.log(`${signal} received, closing server`);
  server.close(() => process.exit(0));          // finish open requests first
  setTimeout(() => process.exit(1), 10_000).unref(); // give up after 10 s
}

process.on("SIGTERM", shutdown); // sent by PM2, Docker and most platforms
process.on("SIGINT", shutdown);  // Ctrl+C

PM2: keep the app running

If you start the app with node server.js over SSH, it stops when you log out, and it stays down after a crash or a reboot. A process manager fixes this. PM2 is the most widely used one for Node.js: it restarts crashed apps, starts them when the server boots, collects logs, and can run one copy per CPU core.

Terminal
npm install -g pm2

pm2 start server.js --name shop-api   # start and give it a name
pm2 list                               # status of all apps
pm2 logs shop-api                      # follow the logs
pm2 restart shop-api                   # restart after updating code
pm2 stop shop-api                      # stop it

For real projects, describe the app in an ecosystem file. PM2 reads it with require(), so in a project with "type": "module" give it the .cjs extension:

JavaScript
// ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: "shop-api",
      script: "./server.js",
      instances: 2,             // or "max" for one per CPU core
      exec_mode: "cluster",
      env: {
        NODE_ENV: "production",
        PORT: 3100,
      },
      max_memory_restart: "300M",
    },
  ],
};
Terminal
pm2 start ecosystem.config.cjs
Output
[PM2] Spawning PM2 daemon with pm2_home=/home/deploy/.pm2
[PM2] PM2 Successfully daemonized
[PM2][WARN] Applications shop-api not running, starting...
[PM2] App [shop-api] launched (2 instances)

PM2 then prints a table showing both instances online in cluster mode with their process IDs, memory and restart count (the ↺ column).

  • Cluster mode starts several copies of your app that share one port, so a 4-core server can use all its cores. Your app must not keep important state in memory (sessions, counters), because each copy has its own memory; use Redis or a database.
  • max_memory_restart restarts a copy that grows too large, a simple safety net against memory leaks.
  • pm2 reload shop-api restarts cluster copies one at a time, so users see no downtime during an update.
  • pm2 startup prints a command that makes PM2 start at boot, and pm2 save remembers the current app list. Run both once on the server.
  • Logs are written to files in ~/.pm2/logs. Install the pm2-logrotate module (pm2 install pm2-logrotate) so they do not fill the disk.

Secrets should not be written in the ecosystem file if it is committed to Git. Load them from a .env file on the server instead, for example with node_args: "--env-file=.env" in the app entry.

Nginx as a reverse proxy

Your Node.js app listens on a high port such as 3100. In front of it, a reverse proxy such as Nginx receives all traffic on ports 80 and 443 and forwards it to the app. This gives you:

  • HTTPS handled in one place, with free certificates from Let's Encrypt.
  • Fast delivery of static files, compression and request size limits.
  • The ability to run several apps on one server under different domain names.
  • No need to run Node.js as root to use port 80 or 443.

A typical site file, /etc/nginx/sites-available/shop:

Text
server {
    listen 80;
    server_name shop.example.in;

    location / {
        proxy_pass http://127.0.0.1:3100;
        proxy_http_version 1.1;
        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;

        # needed for WebSockets
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Enable it and add HTTPS with Certbot, which requests a Let's Encrypt certificate, edits the Nginx file and renews the certificate automatically:

Terminal
sudo ln -s /etc/nginx/sites-available/shop /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d shop.example.in

Behind a proxy, every request seems to come from 127.0.0.1. Tell Express to trust the proxy's X-Forwarded-* headers, so req.ip, req.protocol and secure cookies work correctly:

JavaScript
app.set("trust proxy", 1); // trust one proxy in front of the app

Set it to the number of proxies you really have. Trusting more than that lets clients fake their IP address, which would break rate limiting.

Docker: package the app with its environment

"It works on my machine" is a common problem: the server has a different Node.js version or missing system libraries. Docker packages your app, the exact Node.js version and everything else it needs into an image. A running image is a container, and it behaves the same on your laptop, a test server and production.

Text
# Dockerfile
FROM node:26-alpine
ENV NODE_ENV=production
WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Text
# .dockerignore
node_modules
.env
.git
*.log
Terminal
docker build -t shop-api .
docker run -d --name shop-api -p 3000:3000 --env-file .env --restart unless-stopped shop-api
docker logs -f shop-api

Each line of the Dockerfile matters:

  • FROM node:26-alpine starts from the official Node.js 26 image based on Alpine Linux, which is small. node:26-slim (Debian based) is a common alternative when a package needs extra system libraries.
  • Copying package*.json and running npm ci before copying the rest of the code lets Docker reuse that layer when only your code changes, which makes rebuilds fast.
  • .dockerignore keeps your local node_modules and secrets out of the image.
  • USER node runs the app as the unprivileged user that the official image provides, not as root.
  • CMD ["node", "server.js"] runs Node.js directly, so it receives the SIGTERM signal from docker stop and your graceful shutdown code runs.
  • --restart unless-stopped restarts the container after a crash or a reboot, so you do not need PM2 inside the container.

For an app plus its database, Docker Compose describes several containers in one file:

YAML
# compose.yaml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    env_file: .env
    depends_on:
      - db
    restart: unless-stopped
  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: shop
      MYSQL_ROOT_PASSWORD: change-me
    volumes:
      - dbdata:/var/lib/mysql
volumes:
  dbdata:

docker compose up -d starts both. Inside Compose, the app reaches the database at the host name db, so DB_HOST=db goes in .env. The named volume dbdata keeps the data when the container is replaced.

Common mistakes

  • Hard-coding the port. Many platforms assign one through PORT; always read it.
  • Running npm install (with dev dependencies) on the server. Use npm ci --omit=dev.
  • Copying .env and node_modules into a Docker image. Add them to .dockerignore.
  • Forgetting trust proxy behind Nginx. Secure cookies and rate limiting then misbehave.
  • Keeping sessions in memory with cluster mode or several containers. Use a shared store.
  • No log rotation. Logs grow until the disk is full.

Key points

  • Prepare the app: settings from the environment, NODE_ENV=production, npm ci --omit=dev, a health check and graceful shutdown on SIGTERM.
  • PM2 keeps apps running, restarts crashes, starts on boot, collects logs and runs cluster copies; use an ecosystem.config.cjs file.
  • Nginx as a reverse proxy handles HTTPS, static files and WebSocket upgrades; set app.set("trust proxy", 1) in Express.
  • Docker packages the app with its Node.js version; base it on node:26-alpine or node:26-slim, run as USER node and keep secrets out of the image.
  • Docker Compose runs the app and its database together with volumes for data.

Exercise

Write an ecosystem.config.cjs for an app called school-portal whose entry file is src/server.js. It should run one instance per CPU core in cluster mode, set NODE_ENV to production and PORT to 4000, and restart any instance that uses more than 400 MB. Then write the PM2 commands to start it, make it start at boot and follow its logs.

Show solution
JavaScript
// ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: "school-portal",
      script: "./src/server.js",
      instances: "max",
      exec_mode: "cluster",
      env: {
        NODE_ENV: "production",
        PORT: 4000,
      },
      max_memory_restart: "400M",
    },
  ],
};
Terminal
pm2 start ecosystem.config.cjs
pm2 startup      # run the command it prints, once
pm2 save
pm2 logs school-portal

instances: "max" starts one copy per CPU core. The file uses module.exports and the .cjs extension because PM2 loads it with require(), which works even when the project's package.json says "type": "module".

Quick Quiz

1. Why should a Node.js app read its port from process.env.PORT?

2. What does PM2's cluster mode do?

3. Why is app.set("trust proxy", 1) needed behind Nginx?

4. Why does the Dockerfile copy package*.json and run npm ci before copying the rest of the code?

Learn Node.js with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call