CodeKits.shopNew developer offer

Manage a Node backend with PM2 on Ubuntu

Run a named Node backend with PM2, verify its account and health, persist the correct startup list, and rehearse targeted restart and rollback.

By Jonathan · 5 min read

Published

PM2 lifecycle showing one account, one process and a verified restart
In this article

PM2 is useful when you want a named Node process, restart handling and a familiar command-line view of its logs. The important details are which Unix account owns PM2, which release it starts, and whether the saved startup configuration matches the running application.

This guide uses a disposable Ubuntu server, a supported Node.js LTS release and PM2 installed through a reviewed package-management process. Run PM2 commands as a dedicated existing login account named demo-runner, not root. That account must own its PM2 state and have access to /srv/pm2-demo. These examples are independent of the systemd-only alternative in the VPS learning path: do not let both managers start the same application.

The JavaScript and shell examples were syntax-checked. Reboot persistence and actual Ubuntu process supervision still require your own staging test.

Establish the account and executable paths

Ubuntu terminal — as the demo-runner account
whoami
command -v node
node --version
command -v pm2
pm2 --version
pm2 list

Expect demo-runner as the user. Record the installed versions and absolute executable paths. An administrator's sudo pm2 list can refer to a different PM2 daemon and process list; an empty list there does not prove your application has stopped.

Do not install an unrelated Node version just to match a copied path. If Node is managed under an account-specific version manager, its boot-time path needs deliberate maintenance. The PM2 startup guide explains startup generation and updating it after Node upgrades.

Give the demo a visible health response

Create the following file in a new release directory you control. Keep it separate from any real app:

/srv/pm2-demo/releases/release-001/server.mjs
import http from "node:http";
 
const server = http.createServer((request, response) => {
  if (request.method !== "GET" || request.url !== "/health") {
    response.writeHead(404).end();
    return;
  }
  response.writeHead(200, {
    "Content-Type": "application/json",
    "Cache-Control": "no-store"
  });
  response.end(JSON.stringify({ status: "ok", release: "release-001" }));
});
 
server.listen(3001, "127.0.0.1");
 
function shutdown() {
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(1), 8000).unref();
}
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);

This endpoint proves only that the sample process answers HTTP. It does not verify MySQL or authorization. The server binds explicitly to loopback; setting an environment variable called HOST would do nothing unless the application actually read it. Node's HTTP API documents the server close operation used during shutdown.

Create /srv/pm2-demo/current as a symlink to this release only after confirming it does not already exist. The guarded command below refuses an existing file, directory or dangling symlink; it never replaces the active target:

Ubuntu terminal — point a fresh demo at its first release
if test -e /srv/pm2-demo/current || test -L /srv/pm2-demo/current; then
  printf 'Current already exists; inspect it before continuing.\n' >&2
else
  ln -s /srv/pm2-demo/releases/release-001 /srv/pm2-demo/current
fi
readlink -f /srv/pm2-demo/current

Keep the release identity in your deployment notes.

Declare one named process

Put the configuration below at the stated path, outside any package directory configured as an ES module. Replace /usr/bin/node only with the absolute path you verified:

/srv/pm2-demo/ecosystem.config.js
module.exports = {
  apps: [{
    name: "demo-api",
    cwd: "/srv/pm2-demo/current",
    script: "server.mjs",
    interpreter: "/usr/bin/node",
    instances: 1,
    exec_mode: "fork",
    watch: false,
    autorestart: true,
    restart_delay: 3000,
    min_uptime: "10s",
    max_restarts: 5,
    kill_timeout: 10000,
    env: { NODE_ENV: "production" }
  }]
};

The restart limit helps expose a repeatedly failing startup instead of making it look healthy. The shutdown allowance exceeds this example's eight-second deadline. File watching is off: uploading a partly complete release should not restart production automatically. Do not put credentials in this committed example.

Ubuntu terminal — start only the demo process
pm2 start /srv/pm2-demo/ecosystem.config.js --only demo-api
pm2 describe demo-api
curl --fail --max-time 5 http://127.0.0.1:3001/health
pm2 logs demo-api --lines 40 --nostream

Expect a single online process and JSON naming release-001. A PM2 state alone is not a readiness check. If the port is occupied, identify its owner rather than killing every Node process. Configuration options are described in the ecosystem-file reference.

Use targeted lifecycle commands

Ubuntu terminal — deliberate operations on demo-api
pm2 stop demo-api
pm2 start demo-api
pm2 restart demo-api
pm2 describe demo-api

Run these individually when you intend each action. Stop retains the process entry; delete removes it from PM2's list. Neither operation deletes your database or release files. Avoid restart all when unrelated apps share the account.

A single fork-mode restart can interrupt requests. Do not call it zero-downtime because PM2 has a separate reload feature. If you change the process environment intentionally, PM2's documented --update-env option refreshes it; check exactly which account environment you are passing and never print the full environment to diagnose a missing setting. See process management.

Persist and prove the boot configuration

After verifying that the list contains only intended processes:

Ubuntu terminal — prepare persistence as demo-runner
pm2 save
pm2 startup

The second command prints an administrative installation command. Review its account, home directory and Node/PM2 paths before an authorized administrator runs it. This is a specific startup-unit installation, not a reason to run future application commands with sudo.

Inspect the generated unit, normally pm2-demo-runner.service, and its enabled state. Reboot only the disposable server during a planned test, reconnect, run pm2 list as demo-runner, and request the health endpoint again. A successful pm2 save without that test does not establish boot recovery.

Keep releases and rollback explicit

Deploy a new complete release beside the old one, validate it, update the recorded current symlink and restart only demo-api. Verify the returned release marker through the actual proxy route too. If checks fail, restore the known-good pointer and restart, accounting for database compatibility and writes made during the attempt.

Retain bounded logs and monitor restart counts instead of resetting evidence to make the dashboard green. Continue with staging and rollback, or CodeKits services for an application-specific deployment and recovery handover.