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:
ExecStartneeds an absolute path tonode(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
nodedirectly, notnpm start— npm adds a process layer and doesn’t forward signals reliably. ProtectSystem=strictmakes the filesystem read-only for the service, except paths inReadWritePaths. 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.