App Deployment·7 min read·

Write a systemd Unit for a Node HTTP Server

Ship a Node HTTP process as a systemd service with a dedicated user, a restart policy, and a unit file that survives reboots on Ubuntu Server.

NB

Netbay Developer Relations

Netbay Engineering

On this page

A Node HTTP server that you start in SSH dies when the session dies. nohup and tmux hide that fact until the next reboot. systemd is the process supervisor Ubuntu already runs, and a ten-line unit file is enough to start the app on boot, restart it after a crash, and keep it off root. This post writes that unit for a server that listens on 127.0.0.1 and does not daemonize itself, which is how Node should behave.

Assume /usr/bin/node is on disk, a system user named nodeapp owns /srv/app, and server.js is the entrypoint. If those pieces are missing, install Node 22 on Ubuntu Server first. The unit below is the production wrapper, not a development shortcut.

Type=simple is the right default for Node

Node does not fork. It stays in the foreground and holds the event loop. That is Type=simple: systemd considers the service started as soon as ExecStart is spawned. Type=forking waits for a parent to exit and is for old daemons that double-fork. Type=notify waits for READY=1 over the notify socket; you can add that later with watchdog support, but you do not need it to get a unit running.

ExecStart must be an absolute path. systemd does not source .bashrc. Call /usr/bin/node, not node, and pass the script as an argument. WorkingDirectory is the project root so relative require() calls and a local node_modules resolve the way they do on your laptop.

ini
[Unit]
Description=Example Node HTTP server
Documentation=https://nodejs.org/docs/latest-v22.x/api/
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/app
ExecStart=/usr/bin/node /srv/app/server.js
Restart=on-failure
RestartSec=2
TimeoutStopSec=20
KillSignal=SIGTERM
Environment=NODE_ENV=production
Environment=PORT=3000
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/app
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

Save that as /etc/systemd/system/nodeapp.service. ProtectSystem=strict makes the filesystem read-only except ReadWritePaths. That is the difference between a unit you copied from a blog and a unit that still works after someone umasks a world-writable log directory. LimitNOFILE raises the fd cap; HTTP keep-alives chew through the default 1024 faster than people expect.

Install, enable, and read the first failure

daemon-reload is required every time you edit a unit on disk. enable writes the WantedBy symlink so the service starts at boot. start launches it now. status plus journalctl is the first diagnostic, not a restart loop.

bash
sudo cp nodeapp.service /etc/systemd/system/nodeapp.service
sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp.service
systemctl status nodeapp.service --no-pager -l
journalctl -u nodeapp.service -n 50 --no-pager
systemctl show nodeapp.service -p MainPID -p ActiveState -p ExecMainStatus

If the unit is failed, read the journal before you edit the file again. The common causes are a relative ExecStart, a missing node_modules, a port still bound by a leftover node from your SSH session, and a User= that cannot read the script. systemctl cat nodeapp.service prints the file systemd actually loaded, including drop-ins. Use that before you assume /etc/systemd/system is what is running.

SIGTERM is what systemd sends on stop and restart. Handle it in process so in-flight requests can finish:

javascript
const http = require('http');
const port = Number(process.env.PORT) || 3000;
const server = http.createServer(function (req, res) {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('ok\n');
});
server.listen(port, '127.0.0.1');
function shutdown() {
  server.close(function () { process.exit(0); });
  setTimeout(function () { process.exit(1); }, 15000).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

TimeoutStopSec=20 in the unit must be longer than that 15 second drain. If the process ignores SIGTERM, systemd follows with SIGKILL and you get truncated responses instead of a clean close. That is still better than a hung stop that blocks the next deploy.

systemd supervises Node from boot to SIGTERM multi-user.target WantedBy nodeapp.service Type=simple /usr/bin/node MainPID 127.0.0.1 PORT Restart=on-failure SIGTERM then drain stop is a signal, not a killed SSH session Xeon Platinum VPS, High-Speed SSD, journald captures stdout

Restart policy without a fork bomb

Restart=on-failure covers non-zero exits and signals other than SIGTERM. Restart=always will also restart a clean stop, which fights systemctl stop. RestartSec=2 is a floor so a crash loop does not peg a core. Add StartLimitBurst=5 and StartLimitIntervalSec=60 in the [Unit] section if you want systemd to give up after a burst instead of restarting forever; then you get a failed unit you can alert on instead of a hot loop.

Do not put a node process manager inside the unit. The unit is the process manager. Double supervision is how you get two daemons fighting over the same port. PM2 as ExecStart is a later comparison post; for a single Ubuntu VPS the unit above is the whole stack.

The takeaway: a Node HTTP server is Type=simple, an absolute ExecStart, a dedicated user, and a SIGTERM handler. Enable it, read the journal, and keep the bind on loopback until a reverse proxy is in place.

Need a clean Ubuntu 24.04 box in Lucknow to drop this unit on? Netbay deploys one in under 60 seconds — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance