Deploying Node.js apps with PM2 and pnpm

· Updated · 5 min read

  • node
  • pm2
  • pnpm
  • deploy

PM2 is best known for keeping Node.js processes alive, but it also ships a small deploy tool: from your machine, one command connects to the server over SSH, pulls the latest commit, installs dependencies and reloads the app. No CI pipeline, no containers, just git and SSH.

This article was first published in 2020 and rewritten in October 2026 for PM2 7, pnpm and Node.js 22. The biggest change: Node now reads .env files on its own, so dotenv is gone. The setup, deploy, reload, rollback and startup steps were run for real against a test app; the outputs come from that run, with the host and paths swapped for the example ones.

What you need

  • A Linux server you can reach over SSH with a key, as a regular user (here deploy).
  • Node.js 22 or newer on the server, plus git.
  • Read access from the server to your repository, usually through a deploy key.

On the server, install pnpm and PM2 globally:

bash
npm install --global pnpm pm2

PM2 runs a daemon that owns your app's process, so on the server it should be a global install. That matters later, when it's set to start on boot.

1. Add PM2 to the project

On your machine, PM2 only triggers the deploy, so a dev dependency is enough:

bash
pnpm add --save-dev pm2

You'll run it through pnpm exec, without installing anything globally.

2. The ecosystem file

PM2 reads its settings from an ecosystem file. Name it ecosystem.config.cjs: pnpm init creates projects with "type": "module", and in those a .js file using module.exports fails to load. PM2 finds the .cjs name on its own.

javascript
// The deploy target lives in your local .env, so it never lands in git
const { existsSync } = require('node:fs');
if (existsSync('.env')) process.loadEnvFile();

module.exports = {
  apps: [
    {
      name: 'api',
      script: './src/server.js',
      node_args: '--env-file=.env',
      env_production: {
        NODE_ENV: 'production',
      },
    },
  ],

  deploy: {
    production: {
      user: process.env.DEPLOY_USER,
      host: process.env.DEPLOY_HOST,
      ref: 'origin/main',
      repo: '[email protected]:username/repo.git',
      path: process.env.DEPLOY_PATH,
      'post-setup': 'cp .env.example ../shared/.env',
      'post-deploy': [
        'ln -sfn ../shared/.env .env',
        'pnpm install --frozen-lockfile',
        'pm2 startOrReload ecosystem.config.cjs --env production',
      ].join(' && '),
    },
  },
};

What each part does:

  • process.loadEnvFile() is built into Node and replaces dotenv. It reads the server address from your local .env.
  • node_args: '--env-file=.env' makes Node load the server's .env when the app starts, so the app needs no env library either.
  • post-setup runs once, after the first clone, and copies the example env file into shared/, the folder that survives every deploy.
  • post-deploy runs after each deploy: it links the shared .env into the code, installs exactly what the lockfile says, and starts the app the first time or reloads it after that.

3. Environment files

Two .env files with the same name, on different machines. Keep both out of git:

text
node_modules
.env

Your local .env says where to deploy:

text
DEPLOY_USER=deploy
DEPLOY_HOST=203.0.113.10
DEPLOY_PATH=/home/deploy/www/example.com

The server's .env holds the app's settings. Commit an .env.example as its template:

text
PORT=3000
APP_ENV=production

4. Prepare the server

setup creates the folder structure on the server and clones the repository. Run it once:

bash
pnpm exec pm2 deploy production setup
output
--> Deploying to production environment
--> on host 203.0.113.10
  ○ hook pre-setup
  ○ running setup
  ○ cloning [email protected]:username/repo.git
  ○ full fetch
Cloning into '/home/deploy/www/example.com/source'...
done.
  ○ executing post-setup `cp .env.example ../shared/.env`
  ○ setup complete
--> Success

The server now has:

text
/home/deploy/www/example.com
├── current -> source
├── shared
│   ├── .env
│   ├── logs
│   └── pids
└── source

source holds the code, current points at it, and shared keeps whatever must outlive a deploy. Fill in the real values in shared/.env:

bash
ssh [email protected]
nano ~/www/example.com/shared/.env

5. Deploy

bash
pnpm exec pm2 deploy production
output
--> Deploying to production environment
--> on host 203.0.113.10

  ○ deploying origin/main
  ○ executing pre-deploy-local
  ○ hook pre-deploy
  ○ fetching updates
  ○ full fetch
  ○ resetting HEAD to origin/main
HEAD is now at aef89b2 first version
  ○ executing post-deploy `ln -sfn ../shared/.env .env && pnpm install --frozen-lockfile && pm2 startOrReload ecosystem.config.cjs --env production`
Lockfile is up to date, resolution step is skipped
Packages: +77
Done in 451ms using pnpm v11.24.0
[PM2][WARN] Applications api not running, starting...
[PM2] App [api] launched (1 instances)
  ○ successfully deployed origin/main
--> Success

On the first deploy the app wasn't running yet, so PM2 started it. From the next deploy on, the same command reloads it:

output
[PM2] Applying action reloadProcessId on app [api](ids: [ 0 ])
[PM2] [api](0) ✓

6. Survive a reboot

So far the app lives only as long as the PM2 daemon. On the server, save the process list and register PM2 with the init system:

bash
pm2 save
pm2 startup
output
[PM2] Saving current process list...
[PM2] Successfully saved in /home/deploy/.pm2/dump.pm2
[PM2] Init System found: systemd
[PM2] To setup the Startup Script, copy/paste the following command:
sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u deploy --hp /home/deploy

pm2 startup doesn't install anything: it prints the command to run with sudo. Notice it points at the PM2 binary's path. That's why PM2 is global on the server: a copy from the project's node_modules would point inside a folder that changes with every upgrade.

Day to day

TaskCommand
Deploy the latest commitpnpm exec pm2 deploy production
Roll back one deploypnpm exec pm2 deploy production revert 1
List past deployspnpm exec pm2 deploy production list
Run a command on the serverpnpm exec pm2 deploy production exec "pm2 logs api --lines 20 --nostream"

Deploy always ships what's on origin/main, so push before you deploy. A rollback checks out the previous commit and runs post-deploy again, reinstalling dependencies and reloading the app.

When the deploy can't find node or pnpm

PM2 runs the hooks through a non-interactive SSH session. If Node on the server came from nvm, that session usually doesn't load it: Ubuntu's default ~/.bashrc stops early for non-interactive shells, before the lines nvm adds at the end. The deploy then fails with pnpm: command not found. Move the nvm lines above that early return, or install Node from your distribution's packages.

Sources