Deploying a Node.js app with pm2 and nginx

A working single-server setup: Node.js managed by pm2, with nginx in front handling HTTPS from Let's Encrypt.

Author: Published: Updated: 4 min read

This setup fits an Ubuntu or Debian VPS: the Node.js app listens on a local port, pm2 keeps it running and brings it back after a reboot, and nginx accepts traffic on ports 80 and 443, terminates TLS and proxies requests to Node. All examples use the domain example.com and an app on port 3000.

Installing Node.js

The Node version in a distribution's default repository usually lags one or two major versions behind. There are two sensible ways to get the current LTS.

With a version manager (nvm, fnm). Node lives in the user's home directory, switching versions is trivial, and npm install -g needs no sudo. Take the nvm installer from the README of the nvm-sh/nvm repository, then:

nvm install --lts
nvm alias default 'lts/*'
node -v

With the NodeSource repository. Node is installed system-wide into /usr/bin and updated with the rest of the packages through apt:

curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs

The number in setup_24.x is the major version. Check nodejs.org for the branch currently in Active LTS and use that.

Run the app as a separate unprivileged user such as deploy, with the code in /var/www/app or /home/deploy/app.

Dependencies and environment variables

On the server, install dependencies strictly from package-lock.json:

cd /var/www/app
npm ci --omit=dev

npm ci deletes node_modules and installs exactly the versions recorded in the lock file. If package.json and the lock file disagree, it fails, which keeps unexpected versions out of production. --omit=dev skips devDependencies (linters, test frameworks, bundlers). If the frontend is built on the server, run npm ci, then npm run build, then npm prune --omit=dev.

Secrets (database password, API keys) go into a .env file next to the app, with 600 permissions, and stay out of git. Since Node 20.12 the file can be loaded without third-party packages:

process.loadEnvFile(); // reads .env from the current directory, throws if it is missing
const port = Number(process.env.PORT) || 3000;

On older versions the dotenv package does the same: require("dotenv").config().

Running under pm2

npm install -g pm2
pm2 start server.js --name app

For a persistent configuration, an ecosystem.config.js in the project root is more convenient:

module.exports = {
  apps: [
    {
      name: "app",
      script: "./server.js",
      cwd: "/var/www/app",
      instances: 2,
      exec_mode: "cluster",
      max_memory_restart: "300M",
      env: {
        NODE_ENV: "production",
        PORT: 3000,
      },
    },
  ],
};
pm2 start ecosystem.config.js
pm2 save
pm2 startup

pm2 save writes the current process list to ~/.pm2/dump.pm2. pm2 startup prints a sudo command that you copy and run: it creates a systemd unit, and after a reboot pm2 restores the saved list. Run pm2 save again whenever the set of processes changes.

Everyday commands:

pm2 ls                         # processes, status, memory, restart count
pm2 logs app --lines 100       # last 100 log lines, then live output
pm2 reload app                 # zero-downtime restart in cluster mode
pm2 restart app --update-env   # restart with variables from the current shell environment
pm2 install pm2-logrotate      # log rotation, otherwise ~/.pm2/logs grows forever

pm2 stores a process's environment from its first start. If you change a variable in the shell or in the ecosystem file's env, a plain pm2 restart will not pick it up; you need --update-env. Variables from .env are re-read by the app itself on every start, so a normal restart is enough for those.

nginx as a reverse proxy

Node should listen on the loopback interface only, so port 3000 is not reachable from outside:

app.listen(port, "127.0.0.1");

Config in /etc/nginx/sites-available/example.com:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Host passes the original domain; without it Express sees 127.0.0.1:3000. X-Forwarded-For carries the client IP and X-Forwarded-Proto the scheme (http or https). The map block and the Upgrade/Connection headers are for WebSocket; drop them if the app does not use it.

trust proxy in Express

Behind a proxy, Express ignores X-Forwarded-* headers by default: req.ip is 127.0.0.1, req.protocol is always http, req.secure is always false. That breaks IP-based rate limiting, logs, secure cookies in express-session and any redirect that builds an absolute URL.

app.set("trust proxy", 1);

The value 1 means "trust one proxy in front of the app", which is nginx. true trusts the whole X-Forwarded-For chain, so a client could put any IP into the header. If a CDN or load balancer sits in front of nginx, use 2 or list their addresses.

HTTPS with certbot and redirects

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run

Certbot obtains a Let's Encrypt certificate for both names, adds a listen 443 ssl block and an HTTP redirect to the config. Automatic renewal is installed with the package.

Next, keep a single canonical address: every variant (http, www, extra domains) returns 301 to https://example.com. The resulting config structure:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name www.example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    return 301 https://example.com$request_uri;
}
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    location / {
        proxy_pass http://127.0.0.1:3000;
        # same proxy_set_header lines as above
    }
}

An extra domain (say, an old example.net) goes into server_name of the first and second blocks, but for the HTTPS redirect the certificate has to cover it too: reissue it with additional -d flags. In nginx 1.25.1 and later, HTTP/2 is enabled with http2 on; inside the server block; older versions use listen 443 ssl http2. nginx -v shows the version.

A 301 tells search engines the address has moved permanently, so they consolidate signals from old URLs onto the canonical one. The Express side of search engine setup is covered in SEO for an Express site.

Frequently asked questions

Why use pm2 if systemd exists?

systemd can also restart a Node.js process and start it after a reboot. pm2 adds cluster mode with zero-downtime reloads (pm2 reload), one-command access to logs and metrics, and convenient handling of several apps. For a single simple service, a systemd unit file is enough.

Why is req.ip always 127.0.0.1 in Express behind nginx?

Express sees the connection coming from nginx. Pass the X-Forwarded-For header in nginx with proxy_set_header and enable app.set("trust proxy", 1); req.ip will then take the client address from the header.

Do I need to renew the certbot certificate manually?

No. The certbot package installs a systemd timer or cron job that checks certificates twice a day and renews any that expire within 30 days. Run sudo certbot renew --dry-run to confirm renewal will work.