You don’t need PM2 to keep a Node.js app running. systemd, already on every modern Linux server, starts the app at boot, restarts it when it crashes, collects its logs and can sandbox it. For a single Node service, a unit file of a dozen lines is all it takes.

1. Prepare a user and the app

sudo useradd --system --create-home --home-dir /srv/myapp --shell /usr/sbin/nologin myapp
sudo -u myapp git clone https://github.com/you/myapp.git /srv/myapp/app
cd /srv/myapp/app && sudo -u myapp npm ci --omit=dev

Make the app listen on 127.0.0.1 and a port from process.env.PORT, and handle SIGTERM gracefully (close the server, then exit) — see zero-downtime Node.js deploys.

2. Environment file

/etc/myapp/env (owned by root, mode 640, group myapp):

NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://myapp:[email protected]:5432/myapp
sudo mkdir -p /etc/myapp
sudo chown root:myapp /etc/myapp/env && sudo chmod 640 /etc/myapp/env

3. The unit file

/etc/systemd/system/myapp.service:

[Unit]
Description=My Node.js app
Documentation=https://github.com/you/myapp
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
TimeoutStopSec=20

# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/myapp/app/uploads
CapabilityBoundingSet=
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

Notes:

  • ExecStart needs an absolute path to node (command -v node). systemd doesn’t read your shell profile.
  • nvm users: point to the nvm binary, e.g. /home/deploy/.nvm/versions/node/v22.11.0/bin/node — and update it when you upgrade Node. A system-wide Node from NodeSource avoids this. See installing Node.js.
  • Run node directly, not npm start — npm adds a process layer and doesn’t forward signals reliably.
  • ProtectSystem=strict makes the filesystem read-only for the service, except paths in ReadWritePaths. Adjust to wherever your app writes.

4. Start it

sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp --no-pager
curl -I http://127.0.0.1:3000

5. Logs

console.log output goes to the journal:

journalctl -u myapp -f
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -p err

No log files or rotation to manage. See journalctl.

6. Deploying updates

cd /srv/myapp/app
sudo -u myapp git pull --ff-only
sudo -u myapp npm ci --omit=dev
sudo -u myapp npm run build --if-present
sudo systemctl restart myapp

A restart briefly interrupts requests. For zero downtime, run two instances with a template unit ([email protected] with Environment=PORT=%i) behind an Nginx upstream and restart them one at a time — see blue-green deployment.

Allow a deploy user to restart only this service via sudo:

deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp

7. Check the sandbox

systemd-analyze security myapp

It scores the unit’s exposure and lists further hardening options.

Multiple instances on all CPU cores

Node runs JavaScript on one core. Use a template unit to run one instance per core:

sudo cp /etc/systemd/system/myapp.service /etc/systemd/system/[email protected]
# in [email protected]: Environment=PORT=%i
sudo systemctl enable --now myapp@3001 myapp@3002

…and list both ports in an Nginx upstream. See Nginx load balancing.

systemd or PM2?

systemd: no extra software, strong sandboxing, journald logs. PM2: cluster mode and zero-downtime reloads built in, friendlier CLI for Node developers. See the PM2 guide. Then put Nginx in front: Node.js on a VPS with Nginx.

  • Node.js
  • systemd
  • Deployment