Deploying a Node.js app to a VPS without PM2
Run Next.js, Nuxt or any Node server on Ubuntu as a systemd service behind Nginx, with per-project Node versions, restarts, logs and TLS. No PM2.
Jacob Søndergaard ·
Deploying a Node.js app to a VPS comes down to four jobs: install the right Node version, build the app, keep the server process running, and put Nginx with TLS in front of it. Most guides hand the third job to PM2. On Ubuntu, systemd already does it, and this post uses systemd for a Next.js app and a Nuxt app on one server, each on its own Node version. Every command was run on a fresh Ubuntu 26.04 server.
Commands run as root unless a step says otherwise. Start from a server with SSH keys and a firewall that allows 22, 80 and 443; hardening a new Ubuntu server covers that.
Why systemd and not PM2
PM2 restarts a crashed process, starts your apps at boot, and collects their logs. systemd does all three and is already running as PID 1. To survive a reboot, PM2 itself has to be started by a unit that pm2 startup writes, so with PM2 you run two supervisors, and the outer one only knows about PM2. With systemd directly:
- One place to look.
systemctl statusandjournalctlwork the same for your app, Nginx and PostgreSQL. - Real limits.
MemoryMax=,ProtectSystem=and a dedicated user are a few lines of the unit. - Secrets outside the app.
EnvironmentFile=is read by systemd as root, so the file can be unreadable to the app’s own account.
PM2’s cluster mode, which runs one process per core behind a shared port, has no direct systemd equivalent. With systemd you run a template unit on several ports and let Nginx balance them, which is the pattern the Swift post uses for zero-downtime releases.
Node versions per project with fnm, .nvmrc and engines
Ubuntu’s own nodejs package is one version for the whole machine. fnm installs as many as you need side by side and picks one per project. Install it system-wide, along with Nginx:
apt-get install -y nginx git unzip
case "$(uname -m)" in x86_64) asset=fnm-linux.zip ;; aarch64) asset=fnm-arm64.zip ;; esac
curl -fsSLo /tmp/fnm.zip "https://github.com/Schniz/fnm/releases/download/v1.39.0/$asset"
unzip -o /tmp/fnm.zip -d /usr/local/bin
chmod 755 /usr/local/bin/fnm
fnm --versionfnm’s releases ship without checksums, so pin the version in the URL and update it on purpose rather than downloading latest. Next, the accounts and directories: deploy builds, and web runs the app and owns nothing but a cache directory.
adduser --disabled-password --comment "" deploy
adduser --system --group --no-create-home --home /nonexistent web
install -d -o deploy -g deploy -m 755 /opt/fnm /srv/web /srv/web/releases /srv/web/shared
install -d -o web -g web -m 755 /srv/web/shared/cache
install -d -m 755 /etc/web
install -m 640 -g deploy /dev/null /etc/web/envFNM_DIR=/opt/fnm keeps the Node installs out of deploy’s home directory. Ubuntu creates home directories with mode 750, so a Node binary under /home/deploy is one the web account cannot execute.
The project says which Node it wants. fnm reads .nvmrc or .node-version first, and when neither exists, the newest version that satisfies engines.node in package.json:
24"engines": {
"node": "22.x"
}export FNM_DIR=/opt/fnm
cd ~/web # a checkout of the app
fnm install # reads .nvmrc, .node-version, or engines.node
fnm exec -- node -vOn the test server the Next.js app had .nvmrc set to 24 and the Nuxt app had only engines set to 22.x; fnm installed both and each build ran on its own version. Pin a major, not an exact release, unless you have a reason: each deploy then picks up that line’s security fixes.
Build, then start
npm ci installs exactly what package-lock.json lists, then npm run build produces the server. The service starts only what the build produced. Two things go wrong here more than anything else:
- NODE_ENV=production during install. npm then skips
devDependencies, which is where TypeScript, Tailwind and the bundler’s plugins live. For the Next.js app,npm ciinstalled 22 packages instead of 358 and the build failed. SetNODE_ENVin the unit, where only the running server sees it, not in the environment file the build reads. - NEXT_PUBLIC_ variables are build-time. Next.js writes them into the JavaScript it builds. Setting one only in the service’s environment does nothing, so the deploy script loads the environment file before
npm run build.
A unit file with Restart= and EnvironmentFile=
Configuration and secrets go in /etc/web/env:
DATABASE_URL="postgres://web:change-me@127.0.0.1:5432/web"
GREETING="hello from systemd"
NEXT_PUBLIC_SITE_NAME=ExampleThe file is read twice, by two different parsers: systemd reads it for the service, and the deploy script sources it in bash for the build. Write it so both agree: one KEY=value per line, and double quotes around any value with a space. An unquoted GREETING=hello from systemd is fine for systemd and makes bash try to run a command called from, which stopped the first test deploy. The file is mode 640, group deploy, so the build can read it and the app’s account cannot. The unit:
[Unit]
Description=web (Next.js)
After=network.target
[Service]
Type=simple
User=web
Group=web
WorkingDirectory=/srv/web/current
EnvironmentFile=/etc/web/env
Environment=NODE_ENV=production
Environment=PATH=/srv/web/current/.node/bin:/usr/bin:/bin
ExecStart=/srv/web/current/.node/bin/node node_modules/next/dist/bin/next start --hostname 127.0.0.1 --port 3000
Restart=on-failure
RestartSec=3
SuccessExitStatus=143
TimeoutStopSec=30
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/web/shared/cache
[Install]
WantedBy=multi-user.target- ExecStart. Runs
nodeon Next’s own entry point rather thannpm start, so the process systemd watches and signals is the server itself, not npm. The.nodelink inside each release points at the Node it was built with. - Environment=PATH. Tools the app spawns, and the
#!/usr/bin/env nodescripts innode_modules/.bin, find that same Node. - Restart=on-failure, RestartSec=3. A crash is restarted after three seconds.
systemctl stopis not a failure and stays stopped. - SuccessExitStatus=143.
next startexits with status 143 when systemd stops it. Without this line every deploy’s restart is logged as Failed with result ‘exit-code’. - ProtectSystem=strict, ReadWritePaths=. The filesystem is read-only to the service except the shared cache directory, where Next.js keeps its image and data cache.
For Nuxt, or any server built by Nitro, the unit is the same with a different entry point. Nitro reads HOST and PORT from the environment:
Environment=HOST=127.0.0.1
Environment=PORT=3001
ExecStart=/srv/nuxt/current/.node/bin/node .output/server/index.mjsA plain Express or Fastify server is node server.js, with whatever variable your code reads its port from.
Bind to localhost, put Nginx in front
The app listens on 127.0.0.1:3000 and only Nginx talks to it. A port bound to every interface is one firewall rule away from serving plain HTTP to the internet, past Nginx and its TLS.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name web.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}ln -s /etc/nginx/sites-available/web /etc/nginx/sites-enabled/web
nginx -t && systemctl reload nginxproxy_http_version 1.1 and the two Upgrade lines let WebSocket connections through. X-Forwarded-Proto tells the app the visitor used HTTPS once TLS is on, which matters for redirects and secure cookies.
TLS
With DNS pointing at the server, certbot adds a certificate to that server block and renews it:
apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d web.example.comLet’s Encrypt with Nginx on Ubuntu covers renewal, the timer, and what breaks it.
Releases and restarts on deploy
Each deploy clones into a new directory under releases/, builds there while the old release keeps serving, then moves the current symlink and restarts the service. If the new release does not answer within 30 seconds, the script points current back and restarts again:
#!/usr/bin/env bash
# Build a new release of the app, point current at it, restart the service, roll back if it does not answer.
set -euo pipefail
REPO=git@github.com:you/web.git
ROOT=/srv/web
HEALTH=http://127.0.0.1:3000/
export FNM_DIR=/opt/fnm
release=$ROOT/releases/$(date -u +%Y%m%d%H%M%S)
previous=$(readlink -f "$ROOT/current" || true)
git clone --quiet --depth 1 "$REPO" "$release"
cd "$release"
# .nvmrc, .node-version, or package.json engines.node decides the version.
fnm install --log-level=error
node_home=$(fnm exec -- node -p 'path.dirname(path.dirname(process.execPath))')
export PATH="$node_home/bin:$PATH"
npm ci --no-audit --no-fund
set -a; . /etc/web/env; set +a # NEXT_PUBLIC_* values are compiled in by the build
npm run build
ln -s "$node_home" .node # the service runs the Node this release was built with
rm -rf .next/cache && ln -s "$ROOT/shared/cache" .next/cache # Next.js: a cache that outlives releases
ln -sfn "$release" "$ROOT/current.next"
mv -T "$ROOT/current.next" "$ROOT/current"
sudo systemctl restart web
for attempt in $(seq 1 30); do
if curl -fs -o /dev/null "$HEALTH"; then break; fi
if [ "$attempt" -eq 30 ]; then
echo "$release did not answer; rolling back" >&2
if [ -n "$previous" ]; then
ln -sfn "$previous" "$ROOT/current.next"
mv -T "$ROOT/current.next" "$ROOT/current"
sudo systemctl restart web
fi
exit 1
fi
sleep 1
done
# Keep the five newest releases.
ls -1dt "$ROOT"/releases/* | tail -n +6 | xargs -r rm -rf
echo "$release is live"deploy needs read access to the repository, usually a deploy key in /home/deploy/.ssh. A sudoers rule lets it restart the service and nothing else:
install -m 755 deploy-web /usr/local/bin/deploy-web
echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart web' > /etc/sudoers.d/deploy-web
chmod 440 /etc/sudoers.d/deploy-web
visudo -c
systemctl daemon-reload
systemctl enable web
sudo -iu deploy deploy-webln -sfn followed by mv -T replaces the symlink with a single rename, so there is no moment where current is missing. The restart itself does leave a gap. In a test with requests sent back to back through Nginx during a deploy, 7 of 524 got a 502 while the new Next.js process started. If that matters, run two instances on two ports and switch Nginx between them; the Swift post’s deploy script does that and works unchanged for Node.
A queue consumer or other long-running script is the same unit without Nginx in front. Give it its own unit, restart it in the deploy script after the web service, and it runs the release the web service runs.
Logs in journalctl
Whatever the app writes to stdout and stderr goes to the journal, tagged with the unit, with no log files to rotate:
journalctl -u web -f # follow, like tail -f
journalctl -u web --since "1 hour ago"
journalctl -u web -p warning # warnings and errors only
journalctl -u web -n 100 -o cat # last 100 lines, message text only
systemctl status webjournald caps its own disk use, by default at 10% of the filesystem. Set SystemMaxUse= in /etc/systemd/journald.conf if a chatty app needs a different limit.
How Provisore runs Node sites
Provisore’s Node runtime is this arrangement, managed. It installs Node with fnm so sites on one server can use different versions, offers 26, 24, 22 and defaults new sites to 24, and lets a .nvmrc, .node-version or engines range in the repository override the site’s setting. Your build command runs, then your start command runs as a systemd service behind an Nginx reverse proxy with a Let’s Encrypt certificate. Each deploy is a new release activated behind a health check, workers are daemons restarted onto the new release, and the site’s Nginx and application logs can be read and followed from the app.