dvorakmortonxd.dev/DEPLOYMENT.md

489 lines
13 KiB
Markdown

# 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://<server-ip>: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/<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:
```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`.