13 KiB
Deploying with PM2 and npm
This guide walks through running this Next.js app in production with npm and PM2 on Debian and Alpine Linux. PM2 keeps the process alive, restarts it on crash, and brings it back after a reboot.
The app is a standard Node.js server (next start), so no special runtime is required.
It listens on port 3000 by default and requires Node.js >= 20.9.0 (Next.js 16).
0. Assumptions
- You have a server with
sudo/root access and a way to reach it (SSH). - Your domain's DNS already points at the server.
- You will clone the repo somewhere, e.g.
/srv/dvorakmortonxd.dev(examples below use this path — change it if you like). - You will run the app as a non-root user, e.g.
deploy. - You already have a reverse proxy (Nginx, Caddy, Traefik, Cloudflare Tunnel, …) terminating TLS. See step 11.
The instructions for Node.js installation differ per distro, so do section 1 (Debian) or section 2 (Alpine), then continue with section 3 onward.
1. Debian — install Node.js, npm and PM2
Debian's own nodejs package is often too old for Next.js 16. Install a current LTS build.
Option A: NodeSource (recommended, system-wide)
sudo apt update
sudo apt install -y ca-certificates curl gnupg
# This script adds the NodeSource repo for the current LTS line.
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install -y nodejs
node -v # must print v20.9.0 or newer
npm -v
Option B: nvm (per-user, no root)
Use this if you cannot install system packages, or want a specific version:
sudo apt update
sudo apt install -y curl git build-essential
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
# Reload your shell, then:
source ~/.bashrc
nvm install --lts
nvm alias default 'lts/*'
node -v
With nvm, PM2 must be started from a shell where nvm is loaded, and the generated systemd unit needs the full node path. Step 7 explains how to handle this.
Install PM2 globally
sudo npm install -g pm2
pm2 -v
If sudo npm is not found with nvm, install PM2 per-user instead (no sudo):
npm install -g pm2
Create the deploy user (skip if you already have one)
sudo useradd --create-home --shell /bin/bash deploy
sudo passwd deploy
Give the deploy user ownership of the app directory
sudo mkdir -p /srv/dvorakmortonxd.dev
sudo chown -R deploy:deploy /srv/dvorakmortonxd.dev
2. Alpine — install Node.js, npm and PM2
Alpine ships a small Node.js build in the community repo. Alpine 3.20+ provides Node 22,
which satisfies the >= 20.9.0 requirement.
# Enable/refresh repos first (community must be enabled, it is by default on standard installs).
apk update
apk add --no-cache nodejs npm git
node -v # must print v20.9.0 or newer
npm -v
If node -v is too old, either upgrade Alpine (apk upgrade --available && reboot) or use
the "current" package:
apk add --no-cache nodejs-current npm
Optional build dependencies
Next.js normally uses prebuilt (musl) binaries, so these are usually not needed. Install them only if a dependency has to compile from source:
apk add --no-cache build-base python3 libc6-compat
Install PM2 globally
npm install -g pm2
pm2 -v
On Alpine, PM2 lives under /usr/lib/node_modules/pm2; the OpenRC service generated in
step 10 will reference that path automatically.
Create the deploy user (skip if you already have one)
# -D: no password prompt, -s: usable shell for su/pm2
adduser -D -s /bin/ash deploy
Give the deploy user ownership of the app directory
mkdir -p /srv/dvorakmortonxd.dev
chown -R deploy:deploy /srv/dvorakmortonxd.dev
3. Get the code
As the deploy user:
sudo -iu deploy # Debian
# or: su - deploy # Alpine
cd /srv/dvorakmortonxd.dev
git clone https://git.dvorakmortonxd.dev/dvorakmortonxd/dvorakmortonxd.dev .
If the directory already contains the repo, just cd into it and git pull.
4. Install dependencies and build
Always install all dependencies (including devDependencies) before building — next build
needs TypeScript, Tailwind and the Next.js ESLint config.
cd /srv/dvorakmortonxd.dev
# Clean, lockfile-exact install (use `npm install` if you edited package.json)
npm ci
# Production build (Next.js 16 uses Turbopack)
npm run build
Sanity-check it runs before wiring up PM2:
npm run start
# open http://<server-ip>:3000 , then press Ctrl+C
If the build gets killed on a small VPS (512 MB RAM), see Troubleshooting.
5. Create a PM2 ecosystem file
An ecosystem file keeps the process definition in version control and avoids retyping flags.
Create /srv/dvorakmortonxd.dev/ecosystem.config.js:
module.exports = {
apps: [
{
name: "dvorakmortonxd",
cwd: "/srv/dvorakmortonxd.dev",
// Run the npm script: `npm start`
script: "npm",
args: "start",
// Single instance (no cluster load-balancing needed for a personal site)
instances: 1,
exec_mode: "fork",
// Restart if it uses more than 512 MB (tune to your server)
max_memory_restart: "512M",
// Give the server a moment to shut down cleanly
kill_timeout: 5000,
// Don't watch files in production
watch: false,
env: {
NODE_ENV: "production",
PORT: 3000,
// Bind only to localhost when a reverse proxy runs on the same host.
// Remove this line to listen on all interfaces (0.0.0.0).
HOSTNAME: "127.0.0.1",
},
// Merge stdout/stderr timestamps
time: true,
},
],
};
Why
script: "npm"? PM2 spawnsnpm startfor you, exactly like running it by hand. If you prefer cleaner process signals, point PM2 straight at the Next.js binary instead:script: "./node_modules/next/dist/bin/next", args: "start". Both work; the npm form is shown because that is what this project's scripts use.
6. Start it with PM2
cd /srv/dvorakmortonxd.dev
# Start using the ecosystem file
pm2 start ecosystem.config.js
# Check it is online
pm2 status
pm2 logs dvorakmortonxd --lines 50
If you'd rather skip the ecosystem file, the equivalent one-liner is:
pm2 start npm --name dvorakmortonxd -- start
Useful day-to-day commands (the name is dvorakmortonxd):
pm2 restart dvorakmortonxd # restart
pm2 reload dvorakmortonxd # zero-downtime reload
pm2 stop dvorakmortonxd
pm2 delete dvorakmortonxd
pm2 save # persist the current process list
7. Autostart on boot — Debian (systemd)
With systemd, let PM2 generate and install the unit. Run this as the deploy user
(do not use sudo for the pm2 command itself):
pm2 startup systemd -u deploy --hp /home/deploy
PM2 prints a sudo env PATH=... pm2 startup systemd ... command. Copy/paste and run that
command (it needs root to write the unit). Then freeze the current process list:
pm2 save
# Verify the unit exists
systemctl status pm2-deploy
Test it by rebooting and checking pm2 status afterwards.
nvm users: the generated unit hard-codes the node path under
~/.nvm/.... If you later upgrade Node with nvm, re-runpm2 startup systemd -u deploy --hp /home/deployandpm2 save.
8. Autostart on boot — Alpine (OpenRC)
Alpine uses OpenRC, which PM2 supports. As the deploy user:
pm2 startup openrc -u deploy --hp /home/deploy
PM2 writes /etc/init.d/pm2, makes it executable, and runs rc-update add pm2 default.
Then save the process list:
pm2 save
# Verify and (re)enable at boot
rc-update add pm2 default
rc-service pm2 start
Check after a reboot with pm2 status.
Diskless / read-only Alpine (e.g. Raspberry Pi with
lbu): files written to/etcmay not survive a reboot unless committed. Persist them:lbu add /etc/init.d/pm2 lbu add /home/deploy/.pm2 lbu commit -d
9. Updating the deployment
Each time you pull new code:
sudo -iu deploy # or: su - deploy
cd /srv/dvorakmortonxd.dev
git pull
npm ci # or `npm install` when dependencies changed
npm run build
pm2 reload dvorakmortonxd
pm2 save
pm2 reload restarts the app with minimal downtime. If the build or startup fails, PM2 keeps
the previous process running until the new one is healthy (in fork mode this is a quick
restart; use pm2 restart if you don't need the reload semantics).
10. Logs and monitoring
pm2 logs dvorakmortonxd # live tail
pm2 logs dvorakmortonxd --lines 200
pm2 monit # CPU/memory dashboard
pm2 status
pm2 describe dvorakmortonxd
# Flush logs
pm2 flush dvorakmortonxd
Log files live in ~/.pm2/logs/. To rotate them automatically, install the PM2 logrotate
module:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7
To run next start on a non-default port, change PORT in ecosystem.config.js and
pm2 restart dvorakmortonxd --update-env.
11. Optional reverse proxy
Keep the app bound to 127.0.0.1:3000 (see HOSTNAME in the ecosystem file) and let a proxy
handle TLS and the public hostname.
Cloudflare Tunnel (what the site author uses — no open ports needed):
# Debian — cloudflared is not in the default repos:
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' \
| sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install -y cloudflared
# Alpine — available in the community repo:
apk add --no-cache cloudflared
cloudflared tunnel login
cloudflared tunnel create dvorakmortonxd
cloudflared tunnel route dns dvorakmortonxd dvorakmortonxd.dev
# ~/.cloudflared/config.yml
cat > ~/.cloudflared/config.yml <<'EOF'
tunnel: dvorakmortonxd
credentials-file: /home/deploy/.cloudflared/<TUNNEL-UUID>.json
ingress:
- hostname: dvorakmortonxd.dev
service: http://127.0.0.1:3000
- service: http_status:404
EOF
cloudflared tunnel run dvorakmortonxd
Then install it as a service (sudo cloudflared service install on Debian/systemd, or create an
OpenRC unit on Alpine) so the tunnel also starts at boot.
Nginx on Debian as a simpler alternative:
server {
listen 80;
server_name dvorakmortonxd.dev;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
The Upgrade/Connection headers matter if you later use WebSockets or streaming server
features.
12. Troubleshooting
Node is too old (v18.x etc.)
Next.js 16 refuses to start. Reinstall Node from section 1/2 and confirm node -v is
>= 20.9.0.
Build is killed / Killed / JavaScript heap out of memory
Turbopack builds are memory-hungry. Add swap on small VPSes:
# Debian
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
# Alpine
dd if=/dev/zero of=/swapfile bs=1M count=2048
chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
You can also raise the heap: NODE_OPTIONS=--max-old-space-size=1024 npm run build.
EADDRINUSE (port already in use)
Something else is on 3000. Change PORT in ecosystem.config.js, or stop the other
service: sudo ss -ltnp | grep 3000 (Debian) / netstat -ltnp | grep 3000 (Alpine).
EACCES when running npm install -g
On Debian you may need sudo; on Alpine run as root or prefix with doas. With nvm, never
use sudo.
PM2 says the app is errored
Look at the logs: pm2 logs dvorakmortonxd --err --lines 100. The usual cause is starting PM2
from the wrong directory (so npm start finds no package.json) or a missing .next build
(run npm run build first). The cwd in the ecosystem file prevents the first case.
App doesn't come back after reboot (Alpine)
Confirm the service is enabled (rc-update show | grep pm2) and that you ran pm2 save after
starting the app. On diskless systems, lbu commit your changes as in step 8.
App doesn't come back after reboot (Debian / nvm)
The systemd unit captured the wrong Node path. Re-run the pm2 startup command from step 7
and pm2 save.
pm2 startup prints "Init system not found" on Alpine
Specify it explicitly: pm2 startup openrc -u deploy --hp /home/deploy.