# Deploying a Node.js app with pm2 and nginx

> Step-by-step Node.js deployment on a Linux server: installing Node, npm ci, running under pm2 with autostart, nginx as a reverse proxy, Express trust proxy, certbot HTTPS and 301 redirects.

- URL: https://pakulichev.com/en/wiki/nodejs-pm2-nginx
- Author: Pakulichev (https://pakulichev.com/)
- Language: en
- Updated: 2026-10-16
- Whole site for LLMs: https://pakulichev.com/llms.txt (index), https://pakulichev.com/llms-full.txt (full text)

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:

```bash
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`:

```bash
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`:

```bash
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:

```js
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

```bash
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:

```js
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,
      },
    },
  ],
};
```

```bash
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:

```bash
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:

```js
app.listen(port, "127.0.0.1");
```

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

```nginx
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;
    }
}
```

```bash
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.

```js
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

```bash
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:

```nginx
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](https://pakulichev.com/en/wiki/express-seo).

## FAQ

### 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.
