dvorakmortonxd.dev/DEPLOYMENT.md

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.

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 spawns npm start for 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-run pm2 startup systemd -u deploy --hp /home/deploy and pm2 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 /etc may 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.