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 status and journalctl work 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 --version

fnm’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/env

FNM_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:

.nvmrc
24
package.json
"engines": {
  "node": "22.x"
}
As deploy
export FNM_DIR=/opt/fnm
cd ~/web          # a checkout of the app
fnm install       # reads .nvmrc, .node-version, or engines.node
fnm exec -- node -v

On 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 ci installed 22 packages instead of 358 and the build failed. Set NODE_ENV in 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:

/etc/web/env
DATABASE_URL="postgres://web:change-me@127.0.0.1:5432/web"
GREETING="hello from systemd"
NEXT_PUBLIC_SITE_NAME=Example

The 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:

/etc/systemd/system/web.service
[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 node on Next’s own entry point rather than npm start, so the process systemd watches and signals is the server itself, not npm. The .node link inside each release points at the Node it was built with.
  • Environment=PATH. Tools the app spawns, and the #!/usr/bin/env node scripts in node_modules/.bin, find that same Node.
  • Restart=on-failure, RestartSec=3. A crash is restarted after three seconds. systemctl stop is not a failure and stays stopped.
  • SuccessExitStatus=143. next start exits 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:

/etc/systemd/system/nuxt.service (the lines that differ)
Environment=HOST=127.0.0.1
Environment=PORT=3001
ExecStart=/srv/nuxt/current/.node/bin/node .output/server/index.mjs

A 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.

/etc/nginx/sites-available/web
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 nginx

proxy_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.com

Let’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/local/bin/deploy-web
#!/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-web

ln -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.

Background workers

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 web

journald 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.

Run it on a server you own

Provisore provisions the server, deploys on push and keeps watch, on your own provider account.