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, pm2 is 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:

  1. Install Node from the distro/NodeSource. Path is /usr/bin/node, stable across upgrades. Use nvm/fnm only on your development machine.
  2. If you must use nvm: re-run pm2 unstartup && pm2 startup after every Node version change, and pin the version with nvm alias default 22.
  3. Use interpreter in 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 save serialises the running process list to ~/.pm2/dump.pm2.
  • pm2 startup writes /etc/systemd/system/pm2-.service and enables it.
  • On boot, systemd starts the PM2 daemon, which runs pm2 resurrect and 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' }       // ❌ avoid

Why 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 and kill_timeout always fires.
  • Memory and CPU readings in pm2 monit describe 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:

  1. pnpm prune --prod after building Next.js can remove things the standalone server still needs. Test it before relying on it, or skip pruning entirely — disk is cheap.
  2. pnpm's global bin directory must be on PATH for 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:

  1. 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
  2. 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 save

The 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:404
sudo 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 -Syu can 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-run pm2 startup.
  • Consider nodejs-lts-iron or nodejs-lts-jod from the repos instead of the bleeding-edge nodejs package.
  • 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:

  1. You never ran pm2 save after the last change.
  2. You never ran the sudo command that pm2 startup printed.
  3. The node path in the unit is stale (nvm — see ).
  4. You ran pm2 startup as a different user than the one running the apps.
  5. cwd is 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 build succeeds
  • Swap configured if RAM < 2 GB
  • ecosystem.config.js with absolute cwd, max_memory_restart, min_uptime, max_restarts
  • App binds to 127.0.0.1 only
  • .env.production is chmod 600 and gitignored
  • pm2 startpm2 savepm2 startup → ran the printed sudo command
  • pm2 install pm2-logrotate configured
  • nginx/Caddy reverse proxy with TLS
  • Firewall: only 22/80/443 open
  • Rebooted the server and confirmed the app came back
  • Graceful SIGINT handler in the app
  • Deploy script tested end to end
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论