# Deploying with PM2 and npm This guide walks through running this Next.js app in production with **npm** and **[PM2](https://pm2.keymetrics.io/)** 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](#11-optional-reverse-proxy). 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) ```bash 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: ```bash 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 ```bash sudo npm install -g pm2 pm2 -v ``` If `sudo npm` is not found with nvm, install PM2 per-user instead (no `sudo`): ```bash npm install -g pm2 ``` ### Create the deploy user (skip if you already have one) ```bash sudo useradd --create-home --shell /bin/bash deploy sudo passwd deploy ``` ### Give the deploy user ownership of the app directory ```bash 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. ```bash # 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: ```bash 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: ```bash apk add --no-cache build-base python3 libc6-compat ``` ### Install PM2 globally ```bash 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) ```bash # -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 ```bash mkdir -p /srv/dvorakmortonxd.dev chown -R deploy:deploy /srv/dvorakmortonxd.dev ``` --- ## 3. Get the code As the `deploy` user: ```bash 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. ```bash 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: ```bash npm run start # open http://:3000 , then press Ctrl+C ``` If the build gets killed on a small VPS (512 MB RAM), see [Troubleshooting](#12-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`: ```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 ```bash 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: ```bash pm2 start npm --name dvorakmortonxd -- start ``` Useful day-to-day commands (the name is `dvorakmortonxd`): ```bash 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): ```bash 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: ```bash 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: ```bash 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: ```bash 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: > > ```bash > lbu add /etc/init.d/pm2 > lbu add /home/deploy/.pm2 > lbu commit -d > ``` --- ## 9. Updating the deployment Each time you pull new code: ```bash 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 ```bash 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: ```bash 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): ```bash # 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/.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: ```nginx 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: ```bash # 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`.