PM2 in Production — A Practical Guide
Running Node.js apps (Next.js, Nest, Express, workers) as long-lived services on a VPS or a home server, with PM2.
Covers: installation, ecosystem files, npm/pnpm/yarn differences, boot persistence, cluster mode, zero-downtime reloads, logs, monitoring, deployment, reverse proxy, and the failure modes that actually bite you.
Assumed environment: Linux with systemd (Arch, Ubuntu, Debian, Rocky). Notes for Hostinger VPS and home-server laptops included.
Table of Contents
1. What PM2 actually does
PM2 is a process manager. It does four useful things:
| Concern | What PM2 gives you |
|---|---|
| Keep it alive | Restarts the process when it crashes or exceeds a memory limit |
| Boot persistence | Generates a systemd unit that resurrects your saved process list on reboot |
| Concurrency | Cluster mode: N workers behind Node's built-in load balancer, with rolling reloads |
| Observability | Aggregated stdout/stderr logs, rotation, live CPU/RAM metrics |
What PM2 is not: a reverse proxy, a TLS terminator, a container runtime, or a build system. You still need nginx/Caddy in front, and you still build your app yourself.
The single most important fact: pm2 start alone does not survive a reboot. You need pm2 save + pm2 startup. See .
2. Installation
Install Node.js from the system package manager
On a server, prefer the distro package over a version manager (reasons in ).
# Arch sudo pacman -S nodejs npm # Debian / Ubuntu — NodeSource for a current LTS curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt-get install -y nodejs # Verify node -v && npm -v
Install PM2 globally
sudo npm install -g pm2 # or, if you use pnpm: sudo pnpm add -g pm2 pm2 -v
On Arch,pm2is also in the AUR (pm2), but the npm global install is the more common path and matches PM2's own docs.
Optional: log rotation module
Install this immediately. Without it, PM2 log files grow unbounded and eventually fill the disk.
pm2 install pm2-logrotate pm2 set pm2-logrotate:max_size 10M pm2 set pm2-logrotate:retain 14 pm2 set pm2-logrotate:compress true pm2 set pm2-logrotate:rotateInterval '0 0 * * *' # daily at midnight
3. Node version managers — read this first
This is the number one cause of "PM2 worked, then stopped working after reboot."
pm2 startup generates a systemd unit with the absolute path to the node binary and the pm2 module directory at the time you ran it:
ExecStart=/home/deploy/.nvm/versions/node/v20.11.0/bin/node \ /home/deploy/.nvm/versions/node/v20.11.0/lib/node_modules/pm2/bin/pm2 \ resurrect
The moment you run nvm install 22 and switch, that path may be garbage-collected or simply stop being the version your app needs. On the next reboot the unit fails silently and your site is down.
Options, in order of preference:
- Install Node from the distro/NodeSource. Path is
/usr/bin/node, stable across upgrades. Use nvm/fnm only on your development machine. - If you must use nvm: re-run
pm2 unstartup && pm2 startupafter every Node version change, and pin the version withnvm alias default 22. - Use
interpreterin the ecosystem file to pin an explicit absolute path per app, so a global Node change doesn't silently move your runtime.
{
name: 'api',
script: 'dist/main.js',
interpreter: '/usr/bin/node',
}4. Quick start
Simplest possible case — a plain Node entry point:
cd /srv/apps/api pm2 start dist/main.js --name api pm2 save pm2 startup systemd # prints a sudo command — run it verbatim
Verify:
pm2 list pm2 logs api --lines 50 sudo reboot # then check pm2 list again
Beyond a single trivial process, use an ecosystem file instead of CLI flags. CLI flags aren't version-controlled and nobody remembers what you typed three months ago.
5. The ecosystem file
Create ecosystem.config.js at the repo root. (Use ecosystem.config.cjs if your package.json has "type": "module" — otherwise Node will refuse to load it.)
module.exports = {
apps: [
{
// --- identity ---
name: 'web',
cwd: '/srv/apps/web',
// --- what to run ---
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3000',
interpreter: '/usr/bin/node',
// --- concurrency ---
exec_mode: 'fork', // 'cluster' for multiple workers
instances: 1, // or 'max', or a number, or '-1'
// --- restart policy ---
autorestart: true,
max_memory_restart: '600M',
min_uptime: '30s', // below this, a restart counts as a crash
max_restarts: 10, // give up after 10 crashes inside min_uptime
restart_delay: 2000,
exp_backoff_restart_delay: 100, // exponential backoff instead of fixed delay
// --- lifecycle ---
kill_timeout: 5000, // ms to wait after SIGINT before SIGKILL
listen_timeout: 8000, // ms to wait for the app to be ready (cluster)
wait_ready: false, // true = wait for process.send('ready')
// --- env ---
env: {
NODE_ENV: 'production',
PORT: 3000,
},
// --- logs ---
out_file: '/var/log/pm2/web-out.log',
error_file: '/var/log/pm2/web-error.log',
merge_logs: true,
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
// --- dev only: never enable on a server ---
watch: false,
},
],
};Field reference
| Field | Notes |
|---|---|
name | Unique per PM2 daemon. Used in every CLI command. |
cwd | Always set this absolutely. Relative paths break under systemd resurrect. |
script | Path to the entry file or the binary to execute. |
args | Arguments passed to the script. Note the split: script + args, not one string. |
interpreter | Set to /usr/bin/node, or 'none' when script is a shell script. |
exec_mode | fork (one process, any language) or cluster (Node only, N workers, shared port). |
instances | 'max' = one per CPU core. -1 = cores minus one. A number = exactly that many. |
max_memory_restart | Restarts the worker when RSS exceeds this. Your leak insurance, not a leak fix. |
min_uptime / max_restarts | Together they define the crash-loop circuit breaker. Without them a broken app restarts forever and pins a core. |
kill_timeout | Increase if you have slow graceful shutdown (draining DB pools, finishing requests). |
wait_ready | With process.send('ready') in your app, gives true zero-downtime reloads. |
watch | Filesystem watcher. Development only — on a server it will restart your app mid-deploy. |
cron_restart | e.g. '0 4 * * *' for a nightly restart. A band-aid, not a fix. |
time | true prefixes every log line with a timestamp. |
node_args | Flags for Node itself, e.g. '--max-old-space-size=2048'. |
Multiple apps in one file
module.exports = {
apps: [
{ name: 'web', script: 'node_modules/next/dist/bin/next', args: 'start -p 3000', cwd: '/srv/apps/web' },
{ name: 'api', script: 'dist/main.js', cwd: '/srv/apps/api', instances: 2, exec_mode: 'cluster' },
{ name: 'worker', script: 'dist/worker.js', cwd: '/srv/apps/api', instances: 1 },
{ name: 'cron', script: 'dist/cron.js', cwd: '/srv/apps/api', autorestart: false, cron_restart: '*/15 * * * *' },
],
};Start everything, or just one:
pm2 start ecosystem.config.js pm2 start ecosystem.config.js --only api pm2 restart ecosystem.config.js --only web
6. Boot persistence (the part everyone forgets)
Three commands, in this order:
pm2 start ecosystem.config.js # 1. get the desired state running pm2 save # 2. dump it to ~/.pm2/dump.pm2 pm2 startup systemd # 3. generate the systemd unit
Step 3 prints something like:
sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u deploy --hp /home/deploy
Run that printed command verbatim. PM2 doesn't run it for you — it can't, it isn't root.
How it actually works
pm2 saveserialises the running process list to~/.pm2/dump.pm2.pm2 startupwrites/etc/systemd/system/pm2-.serviceand enables it.- On boot, systemd starts the PM2 daemon, which runs
pm2 resurrectand replays the dump.
The critical rule
pm2 save is a snapshot, not a sync. Any change to your process list — adding an app, changing a port, deleting one — is lost on reboot unless you run pm2 save again.
Make it muscle memory:
pm2 restart ecosystem.config.js && pm2 save
Verifying it works
Don't trust it until you've tested it:
systemctl status pm2-$USER systemctl is-enabled pm2-$USER # should print "enabled" cat ~/.pm2/dump.pm2 | head -40 # confirm your apps are in there sudo reboot # after it comes back: pm2 list
Undoing it
pm2 unstartup systemd # prints another sudo command — run it
If you deploy as a dedicated user
Run all three commands as that user, not as root. Each Linux user gets their own PM2 daemon, their own ~/.pm2, and their own systemd unit. sudo pm2 list and pm2 list show two completely different worlds — this confuses people constantly.
sudo useradd -m -s /bin/bash deploy sudo -iu deploy # now do everything as deploy
7. Package manager specifics: npm / pnpm / yarn / bun
Don't put your package manager in script
Tempting:
{ script: 'npm', args: 'start' } // ❌ avoid
{ script: 'pnpm', args: 'start' } // ❌ avoidWhy it's bad:
- You get an extra process layer. PM2 monitors the package manager, not your app.
- Signals (
SIGINT/SIGTERM) may not forward to the child, so graceful shutdown breaks andkill_timeoutalways fires. - Memory and CPU readings in
pm2 monitdescribe the wrapper, not your app. - Cluster mode does not work at all — PM2 can only fork Node scripts, not arbitrary binaries.
Point script at the real JS entry point instead.
| Framework | Correct script / args |
|---|---|
| Next.js (default) | script: 'node_modules/next/dist/bin/next', args: 'start -p 3000' |
| Next.js (standalone) | script: '.next/standalone/server.js' |
| NestJS | script: 'dist/main.js' |
| Express / Fastify | script: 'dist/server.js' or src/index.js |
| Vite SSR | script: 'server.js' |
| Remix | script: 'node_modules/@remix-run/serve/dist/cli.js', args: 'build/index.js' |
| Nuxt 3 | script: '.output/server/index.mjs' |
| Astro (node adapter) | script: './dist/server/entry.mjs' |
pnpm
pnpm's symlinked node_modules layout works fine with PM2 as long as you reference the real path. node_modules/next/dist/bin/next still resolves — pnpm puts a symlink at node_modules/next pointing into node_modules/.pnpm/.
Install and build on the server:
cd /srv/apps/web pnpm install --frozen-lockfile --prod=false # need devDeps to build pnpm build pnpm prune --prod # optional: shrink after build pm2 reload ecosystem.config.js --only web
Two pnpm gotchas:
pnpm prune --prodafter building Next.js can remove things the standalone server still needs. Test it before relying on it, or skip pruning entirely — disk is cheap.- pnpm's global bin directory must be on
PATHfor the systemd unit if you installed PM2 via pnpm. Safer: install PM2 with npm globally, use pnpm for project dependencies. Mixing is fine.
If you genuinely need to run a pnpm script (e.g. it does non-trivial setup), do it explicitly and accept the wrapper cost:
{
name: 'web',
script: '/usr/bin/pnpm',
args: 'start',
interpreter: 'none', // don't run pnpm through node
cwd: '/srv/apps/web',
}interpreter: 'none' is required here, otherwise PM2 tries node /usr/bin/pnpm.
Yarn
Same reasoning. Yarn Berry (PnP mode) is the awkward case — with PnP there is no conventional node_modules, so paths like node_modules/next/dist/bin/next don't exist. Either set nodeLinker: node-modules in .yarnrc.yml, or run through yarn with interpreter: 'none'.
Bun
PM2's cluster mode is Node-specific and won't work with Bun. For fork mode:
{
name: 'api',
script: 'src/index.ts',
interpreter: '/home/deploy/.bun/bin/bun',
}Honestly, if you're all-in on Bun, systemd is a cleaner fit than PM2.
Lockfile discipline
Always use the frozen/CI install on a server. Never let the server resolve fresh versions.
npm ci pnpm install --frozen-lockfile yarn install --immutable
8. Scenario catalogue
8.1 Next.js — standard build
cd /srv/apps/web pnpm install --frozen-lockfile pnpm build
// ecosystem.config.js
module.exports = {
apps: [{
name: 'web',
cwd: '/srv/apps/web',
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3000',
interpreter: '/usr/bin/node',
exec_mode: 'fork',
instances: 1,
env: { NODE_ENV: 'production', PORT: 3000 },
max_memory_restart: '600M',
}],
};8.2 Next.js — standalone output (recommended)
output: 'standalone' produces a self-contained server with a pruned node_modules. Much smaller, faster to boot, and it makes cluster mode straightforward.
// next.config.js
module.exports = { output: 'standalone' };After pnpm build, the static assets are not copied automatically — this trips up everyone:
cp -r .next/static .next/standalone/.next/static cp -r public .next/standalone/public
{
name: 'web',
cwd: '/srv/apps/web',
script: '.next/standalone/server.js',
exec_mode: 'cluster',
instances: 2,
env: { NODE_ENV: 'production', PORT: 3000, HOSTNAME: '127.0.0.1' },
}Set HOSTNAME=127.0.0.1 so the app only binds to loopback and can't be reached bypassing your reverse proxy.
8.3 NestJS API in cluster mode
{
name: 'api',
cwd: '/srv/apps/api',
script: 'dist/main.js',
exec_mode: 'cluster',
instances: 'max',
max_memory_restart: '500M',
kill_timeout: 10000, // let in-flight requests drain
env: { NODE_ENV: 'production', PORT: 4000 },
}8.4 Background worker (BullMQ, queue consumer)
Workers should be fork mode, not cluster — you usually control concurrency inside the worker itself, and cluster's port sharing is meaningless for a non-HTTP process.
{
name: 'worker',
cwd: '/srv/apps/api',
script: 'dist/worker.js',
exec_mode: 'fork',
instances: 1,
max_memory_restart: '1G',
kill_timeout: 30000, // let the current job finish
}8.5 Scheduled job (instead of cron)
{
name: 'nightly-report',
script: 'dist/jobs/report.js',
cwd: '/srv/apps/api',
autorestart: false, // it's meant to exit
cron_restart: '0 3 * * *', // re-run at 03:00 daily
}For anything non-trivial, a real systemd timer or an in-app scheduler is more robust than cron_restart.
8.6 Multiple apps, multiple ports, one server
module.exports = {
apps: [
{ name: 'site-a', cwd: '/srv/apps/site-a', script: '.next/standalone/server.js', env: { PORT: 3001, HOSTNAME: '127.0.0.1' } },
{ name: 'site-b', cwd: '/srv/apps/site-b', script: '.next/standalone/server.js', env: { PORT: 3002, HOSTNAME: '127.0.0.1' } },
{ name: 'api', cwd: '/srv/apps/api', script: 'dist/main.js', exec_mode: 'cluster', instances: 2, env: { PORT: 4000 } },
],
};nginx routes by hostname; only nginx is exposed publicly. See .
8.7 Staging and production on the same box
pm2 start ecosystem.config.js --only web --env production pm2 start ecosystem.staging.config.js --only web-staging
Or use PM2's env blocks:
{
name: 'web',
script: '.next/standalone/server.js',
env: { NODE_ENV: 'production', PORT: 3000 },
env_staging: { NODE_ENV: 'production', PORT: 3100, API_URL: 'https://staging-api.example.com' },
}pm2 start ecosystem.config.js --env staging
Note: --env only takes effect on start and on restart --update-env. A bare pm2 restart reuses the old environment. This surprises people.
8.8 Non-Node processes
PM2 can supervise anything:
{ name: 'py-svc', script: 'app.py', interpreter: 'python3' }
{ name: 'go-svc', script: './server', interpreter: 'none' }
{ name: 'sh-svc', script: './run.sh', interpreter: 'bash' }Useful in a pinch, but for non-Node services systemd is usually the better tool.
9. Cluster mode and zero-downtime reloads
When cluster mode is worth it
- Your app is CPU-bound in userland (heavy SSR, image processing, big JSON serialisation).
- You have more than one core.
- Your app is genuinely stateless.
When it isn't
- Any in-memory state. Sessions, caches, rate-limit counters, WebSocket connection maps — each worker gets its own copy and users bounce between them. Move state to Redis first.
- Single-core VPS. More workers than cores just adds context switching.
- Mostly I/O-bound work. Node's event loop already handles that; extra workers mostly add RAM cost.
Configuring
{ exec_mode: 'cluster', instances: 'max' } // one per core
{ exec_mode: 'cluster', instances: -1 } // cores minus one
{ exec_mode: 'cluster', instances: 4 }Rolling reload
pm2 reload web # restarts workers one at a time — no dropped connections pm2 restart web # kills everything, then starts — brief downtime
reload only works in cluster mode. In fork mode it degrades to a plain restart.
True zero-downtime with wait_ready
By default PM2 considers a worker "up" as soon as it's spawned, which can route traffic before your server is actually listening. Fix it:
{ wait_ready: true, listen_timeout: 10000 }// in your app, after the server is listening
server.listen(port, () => {
if (process.send) process.send('ready');
});Graceful shutdown
Handle SIGINT so in-flight requests complete:
process.on('SIGINT', async () => {
await new Promise((resolve) => server.close(resolve));
await db.end();
await redis.quit();
process.exit(0);
});PM2 sends SIGINT, waits kill_timeout ms, then SIGKILL. If your drain takes 20 seconds, set kill_timeout: 25000.
10. Environment variables and secrets
Option A — .env file loaded by the app (recommended)
Keep secrets out of the ecosystem file, which is in git.
# /srv/apps/web/.env.production — chmod 600, owned by the deploy user DATABASE_URL=postgres://... NEXTAUTH_SECRET=...
Next.js loads .env.production automatically. For other frameworks use dotenv or Node 20+'s --env-file:
{ node_args: '--env-file=.env.production' }Option B — env_file in the ecosystem config
{ name: 'api', script: 'dist/main.js', env_file: '.env.production' }Option C — inline env (non-secret values only)
{ env: { NODE_ENV: 'production', PORT: 3000, LOG_LEVEL: 'info' } }The --update-env trap
PM2 caches the environment from the moment a process was started. This does not pick up changes:
pm2 restart web # ❌ reuses the cached env
This does:
pm2 restart web --update-env pm2 reload web --update-env
After changing any env var, restart with --update-env and then pm2 save.
Build-time vs runtime in Next.js
NEXT_PUBLIC_* variables are inlined into the client bundle at build time. Changing them and restarting PM2 does nothing — you must rebuild. Only server-side variables are read at runtime.
Never commit
.env .env.* !.env.example
11. Logging
Where logs go
By default: ~/.pm2/logs/-out.log and -error.log.
pm2 logs # all apps, live pm2 logs web # one app pm2 logs web --lines 200 pm2 logs web --err # stderr only pm2 logs --timestamp pm2 flush # truncate all log files pm2 flush web
Custom paths
{
out_file: '/var/log/pm2/web-out.log',
error_file: '/var/log/pm2/web-error.log',
merge_logs: true, // one file for all cluster workers
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
}sudo mkdir -p /var/log/pm2 sudo chown deploy:deploy /var/log/pm2
Rotation is mandatory
Repeating from because it matters: a chatty app fills the disk in weeks, and a full disk takes down everything on the box.
pm2 install pm2-logrotate pm2 set pm2-logrotate:max_size 10M pm2 set pm2-logrotate:retain 14 pm2 set pm2-logrotate:compress true pm2 conf pm2-logrotate # review current settings
Structured logging
If your app uses pino or winston writing JSON to stdout, set merge_logs: true and turn PM2's own timestamps off so you don't corrupt the JSON lines:
{ time: false, log_date_format: '' }12. Monitoring, memory, and health
pm2 list # status table pm2 monit # live TUI: CPU, memory, logs pm2 show web # everything about one app pm2 describe web pm2 prettylist # full JSON pm2 jlist # raw JSON, good for scripting
Machine-readable status for a health check script:
pm2 jlist | jq -r '.[] | "\(.name) \(.pm2_env.status) \(.monit.memory) \(.pm2_env.restart_time)"'
Memory limits
{ max_memory_restart: '600M' }This restarts a worker when RSS crosses the threshold. It's a safety net for slow leaks, not a substitute for finding the leak. If it fires several times a day, profile the app.
Also constrain the V8 heap explicitly on small VPS instances:
{ node_args: '--max-old-space-size=512' }Otherwise V8 sizes its heap based on total system memory and a single app can OOM the whole machine.
Watching for crash loops
pm2 list shows a restart counter. Sudden growth means a crash loop. The min_uptime + max_restarts combination stops the loop instead of letting it spin forever:
{ min_uptime: '30s', max_restarts: 10 }After 10 restarts inside 30-second windows, PM2 marks the app errored and stops trying. Check pm2 logs web --err to find out why.
PM2 Plus
pm2 plus links to PM2's hosted dashboard (free tier available). Convenient, but it's a third-party service receiving your metrics. On a home server, pm2 monit over SSH is usually enough.
13. Deploying updates
Manual deploy script
Simple, transparent, and easy to debug. Put this at /srv/apps/web/deploy.sh:
#!/usr/bin/env bash set -euo pipefail APP_DIR=/srv/apps/web APP_NAME=web cd "$APP_DIR" echo "→ Fetching" git fetch --all git reset --hard origin/main echo "→ Installing" pnpm install --frozen-lockfile echo "→ Building" pnpm build # only needed for standalone output if [ -d .next/standalone ]; then cp -r .next/static .next/standalone/.next/static [ -d public ] && cp -r public .next/standalone/public fi echo "→ Reloading" pm2 reload "$APP_NAME" --update-env pm2 save echo "✓ Deployed $(git rev-parse --short HEAD)"
chmod +x deploy.sh ./deploy.sh
Build before reload, not after. If the build fails, the old version keeps serving traffic.
Note on building on small VPS instances
A Next.js build can need 1–2 GB of RAM. On a 1 GB Hostinger plan the build gets OOM-killed. Two options:
- Add swap (simplest):
sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
- Build in CI, ship the artifact. Better for anything serious — the server never needs devDependencies or a toolchain.
GitHub Actions → server over SSH
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Package
run: |
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
tar -czf build.tar.gz -C .next/standalone .
- name: Upload
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
source: build.tar.gz
target: /srv/apps/web/releases
- name: Activate
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
set -e
cd /srv/apps/web
REL=releases/$(date +%s)
mkdir -p "$REL"
tar -xzf releases/build.tar.gz -C "$REL"
ln -sfn "$PWD/$REL" current
pm2 reload web --update-env
pm2 saveThe symlink swap gives you an instant rollback: repoint current at the previous release directory and reload.
pm2 deploy (built-in)
PM2 ships its own git-based deployment system. It works, but it's rigid and poorly documented compared to a 20-line shell script. Included for completeness:
// in ecosystem.config.js
module.exports = {
apps: [/* ... */],
deploy: {
production: {
user: 'deploy',
host: '203.0.113.10',
ref: 'origin/main',
repo: 'git@github.com:you/repo.git',
path: '/srv/apps/web',
'post-deploy': 'pnpm install --frozen-lockfile && pnpm build && pm2 reload ecosystem.config.js --env production && pm2 save',
},
},
};pm2 deploy production setup # first time only pm2 deploy production pm2 deploy production revert 1
14. Reverse proxy (nginx / Caddy)
Never expose a Node process directly on port 80/443. Put a proxy in front for TLS, compression, static file serving, rate limiting, and the ability to run several apps on one IP.
Caddy (easiest — automatic HTTPS)
# /etc/caddy/Caddyfile
example.com {
reverse_proxy 127.0.0.1:3000
encode gzip zstd
}
api.example.com {
reverse_proxy 127.0.0.1:4000
}
sudo systemctl reload caddy
That's the whole configuration. Caddy obtains and renews Let's Encrypt certificates automatically.
nginx
# /etc/nginx/sites-available/example.com
upstream nextjs {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
client_max_body_size 20M;
location / {
proxy_pass http://nextjs;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
}
# let nginx serve immutable build assets directly
location /_next/static/ {
alias /srv/apps/web/.next/static/;
expires 365d;
access_log off;
add_header Cache-Control "public, immutable";
}
}sudo certbot --nginx -d example.com -d www.example.com sudo nginx -t && sudo systemctl reload nginx
Bind to loopback only
Make sure the Node process itself is unreachable from outside:
{ env: { HOSTNAME: '127.0.0.1', PORT: 3000 } }and confirm:
ss -tlnp | grep 3000 # should show 127.0.0.1:3000, not 0.0.0.0:3000
15. Home server (laptop) specifics
Stop it sleeping when you close the lid
sudo nano /etc/systemd/logind.conf
HandleLidSwitch=ignore HandleLidSwitchDocked=ignore HandleLidSwitchExternalPower=ignore IdleAction=ignore
sudo systemctl restart systemd-logind
Also disable any suspend targets:
sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
Auto power-on after a power cut
Set "Restore on AC Power Loss" (or "AC Back" / "Power On After Power Failure") to Power On in the BIOS/UEFI. Without this, a brownout leaves the machine off until you press the button. A laptop's battery buys you some of this for free, which is one real advantage over a desktop.
Dynamic IP
Home connections rarely have a static IP. Options:
- DDNS — DuckDNS, Cloudflare API updater, or
ddclient. Combine with port forwarding on the router. - Cloudflare Tunnel — no port forwarding, no public IP needed, works behind CGNAT. Usually the right answer for a home server.
- Tailscale — if the service is only for you and a few people, skip public exposure entirely.
Cloudflare Tunnel setup:
# Arch sudo pacman -S cloudflared cloudflared tunnel login cloudflared tunnel create home cloudflared tunnel route dns home app.example.com
# ~/.cloudflared/config.yml
tunnel:
credentials-file: /home/you/.cloudflared/.json
ingress:
- hostname: app.example.com
service: http://localhost:3000
- service: http_status:404sudo cloudflared service install sudo systemctl enable --now cloudflared
CGNAT check
If your router's WAN IP starts with 100.64.–100.127., you're behind carrier-grade NAT and port forwarding cannot work. Tunnel or VPN is your only path.
Arch-specific notes
- Arch is rolling-release. A
pacman -Syucan bump Node from 22 to 24 and break native modules (sharp,bcrypt,better-sqlite3). After every major Node bump: reinstall dependencies, rebuild,pm2 update, and re-runpm2 startup. - Consider
nodejs-lts-ironornodejs-lts-jodfrom the repos instead of the bleeding-edgenodejspackage. - Enable
systemd-timesyncd— clock drift breaks TLS and JWT validation.
Journal and disk
A home server with a small SSD fills up quietly. Cap the journal:
sudo journalctl --vacuum-size=200M # and in /etc/systemd/journald.conf: # SystemMaxUse=200M
16. Security and hardening
Run as a non-root user
sudo useradd -m -s /bin/bash deploy sudo usermod -aG www-data deploy # if nginx needs to read your static dir sudo -iu deploy
Never sudo pm2 start. A compromised app running as root owns the machine.
Firewall
# Arch sudo pacman -S ufw sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow 22/tcp sudo ufw allow 80,443/tcp sudo ufw enable sudo ufw status verbose
Note that app ports (3000, 4000) are deliberately not opened — only nginx reaches them, over loopback.
SSH
# /etc/ssh/sshd_config PermitRootLogin no PasswordAuthentication no PubkeyAuthentication yes
sudo systemctl restart sshd
Add fail2ban if the box is on a public IP.
File permissions
chmod 600 /srv/apps/web/.env.production chown deploy:deploy /srv/apps/web/.env.production
Keep PM2 current
sudo npm install -g pm2@latest pm2 update # restarts the in-memory daemon with the new version pm2 save
pm2 update is required after upgrading — otherwise the old daemon keeps running from memory and you get confusing version mismatches.
17. PM2 vs systemd vs Docker
| PM2 | systemd | Docker / Compose | |
|---|---|---|---|
| Setup effort | Low | Low | Medium |
| Extra dependency | Yes (Node global) | No | Yes (daemon) |
| Survives reboot | Via generated unit | Native | restart: unless-stopped |
| Cluster / multi-worker | Built in | Manual (@ templates) | Multiple containers |
| Zero-downtime reload | pm2 reload | Manual | Rolling update / blue-green |
| Log handling | Own files + rotate module | journald | Docker log drivers |
| Resource limits | max_memory_restart | cgroups (real limits) | cgroups (real limits) |
| Isolation | None | Partial (namespaces available) | Strong |
| Non-root management | Yes | Needs sudo (or user units) | Needs group/rootless setup |
| Multi-language | Yes, awkwardly | Yes, naturally | Yes, naturally |
Rough guidance:
- One or two Node apps on a VPS you control → PM2 is fine and pleasant. Or plain systemd, which is one file and zero dependencies.
- Many Node apps, frequent starts/stops, you want
pm2 monit→ PM2 earns its place. - Mixed stack (Node + Postgres + Redis + Python), or you need reproducibility → Docker Compose.
- You want the fewest moving parts and hard resource limits → systemd.
The equivalent systemd unit
If you'd rather skip PM2 entirely:
# /etc/systemd/system/web.service [Unit] Description=Next.js web app After=network.target [Service] Type=simple User=deploy Group=deploy WorkingDirectory=/srv/apps/web Environment=NODE_ENV=production Environment=PORT=3000 Environment=HOSTNAME=127.0.0.1 EnvironmentFile=/srv/apps/web/.env.production ExecStart=/usr/bin/node .next/standalone/server.js Restart=always RestartSec=5 StandardOutput=journal StandardError=journal # hardening NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=read-only ReadWritePaths=/srv/apps/web/.next/cache MemoryMax=800M [Install] WantedBy=multi-user.target
sudo systemctl daemon-reload sudo systemctl enable --now web sudo systemctl status web journalctl -u web -f
MemoryMax here is a real kernel-enforced cgroup limit, which is stronger than PM2's max_memory_restart polling.
You can also run both — systemd for infrastructure services, PM2 for your app fleet. That's a common and reasonable setup.
18. Troubleshooting
App doesn't come back after reboot
systemctl status pm2-$USER # is the unit enabled and running? journalctl -u pm2-$USER -n 100 # what did it say on boot? cat ~/.pm2/dump.pm2 # is your app actually in the dump?
Usual causes, in order of frequency:
- You never ran
pm2 saveafter the last change. - You never ran the
sudocommand thatpm2 startupprinted. - The
nodepath in the unit is stale (nvm — see ). - You ran
pm2 startupas a different user than the one running the apps. cwdis relative, so the resurrect can't find the script.
Status is errored
pm2 logs web --err --lines 100 pm2 describe web # shows the exit code and restart count
Common: missing .env file, port already in use, build output not present, native module compiled against a different Node version.
EADDRINUSE
ss -tlnp | grep 3000 pm2 list # is the same app started twice? sudo lsof -i :3000
Often caused by a stale process after pm2 kill was skipped, or the same ecosystem file started twice under different names.
Constant restarts / crash loop
pm2 describe web | grep -i restart
Set the circuit breaker so it stops burning CPU:
{ min_uptime: '30s', max_restarts: 10 }Then read the error log to find the actual crash.
Environment changes not applying
Use --update-env. See . And remember NEXT_PUBLIC_* needs a rebuild, not a restart.
pm2 list is empty but the app is running
You're looking at a different user's daemon. pm2 as deploy and sudo pm2 as root are two separate daemons with separate process lists.
whoami sudo -iu deploy pm2 list
PM2 daemon acting strange after an upgrade
pm2 update
If that doesn't help, the nuclear option:
pm2 save # snapshot first pm2 kill # kill the daemon entirely pm2 resurrect # bring it back from the dump
Native modules failing after a Node upgrade
cd /srv/apps/api rm -rf node_modules pnpm install --frozen-lockfile pnpm rebuild pm2 restart api
High memory with no obvious leak
Check whether V8 has simply been allowed a huge heap:
pm2 describe api | grep -i node_args
Constrain it:
{ node_args: '--max-old-space-size=512' }Disk full
du -sh ~/.pm2/logs/* pm2 flush
Then install pm2-logrotate and stop it happening again.
19. Command reference
Lifecycle
pm2 start ecosystem.config.js pm2 start ecosystem.config.js --only web pm2 start ecosystem.config.js --env staging pm2 start app.js --name api -i max pm2 stop web pm2 stop all pm2 restart web pm2 restart all pm2 reload web # zero-downtime, cluster mode only pm2 delete web pm2 delete all pm2 restart web --update-env
Inspecting
pm2 list pm2 status pm2 show web pm2 describe web pm2 monit pm2 jlist # JSON, for scripts pm2 env 0 # env of process id 0
Logs
pm2 logs pm2 logs web --lines 200 pm2 logs web --err pm2 logs --timestamp pm2 flush pm2 reloadLogs # after external log rotation
Persistence
pm2 save pm2 resurrect pm2 startup systemd pm2 unstartup systemd pm2 dump # alias of save
Scaling
pm2 scale api 4 # set to exactly 4 workers pm2 scale api +2 # add 2
Maintenance
pm2 update # restart daemon after upgrading pm2 pm2 kill # kill the daemon and all processes pm2 ping # is the daemon alive? pm2 reset web # reset restart counters
Modules
pm2 install pm2-logrotate pm2 uninstall pm2-logrotate pm2 set pm2-logrotate:max_size 10M pm2 conf
Checklist for a new deployment
- Node installed from distro/NodeSource, not nvm
- Dedicated non-root user (
deploy) - Repo cloned to
/srv/apps/ -
pnpm install --frozen-lockfile && pnpm buildsucceeds - Swap configured if RAM < 2 GB
-
ecosystem.config.jswith absolutecwd,max_memory_restart,min_uptime,max_restarts - App binds to
127.0.0.1only -
.env.productionischmod 600and gitignored -
pm2 start→pm2 save→pm2 startup→ ran the printed sudo command -
pm2 install pm2-logrotateconfigured - nginx/Caddy reverse proxy with TLS
- Firewall: only 22/80/443 open
- Rebooted the server and confirmed the app came back
- Graceful
SIGINThandler in the app - Deploy script tested end to end