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:
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:
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.
// 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 replacesdotenv. It reads the server address from your local.env.node_args: '--env-file=.env'makes Node load the server's.envwhen the app starts, so the app needs no env library either.post-setupruns once, after the first clone, and copies the example env file intoshared/, the folder that survives every deploy.post-deployruns after each deploy: it links the shared.envinto 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:
node_modules
.env
Your local .env says where to deploy:
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:
PORT=3000
APP_ENV=production
4. Prepare the server
setup creates the folder structure on the server and clones the repository. Run it once:
pnpm exec pm2 deploy production setup
--> 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:
/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:
ssh [email protected]
nano ~/www/example.com/shared/.env
5. Deploy
pnpm exec pm2 deploy production
--> 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:
[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:
pm2 save
pm2 startup
[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
| Task | Command |
|---|---|
| Deploy the latest commit | pnpm exec pm2 deploy production |
| Roll back one deploy | pnpm exec pm2 deploy production revert 1 |
| List past deploys | pnpm exec pm2 deploy production list |
| Run a command on the server | pnpm 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.