A Node.js service that belongs on a Linux server should not depend on an interactive shell staying open. systemd can own the launch command, runtime account, restart policy, boot enablement, and journal logs for the same application process.
The deployment pattern uses a dedicated system account, a fixed application directory, a separate environment file, and a local service unit. The Node.js process stays in the foreground so systemd can track the real main process.
A loopback-only bind fits a backend service that will later sit behind Nginx, Apache, or a load balancer. Replace the service name, paths, and response text with the real application, keep secrets out of the unit file, and verify both the unit state and an application request before handing the service to users.
$ sudo useradd --system --home-dir /opt/inventory-api --shell /usr/sbin/nologin --user-group inventory-api
A service account keeps the process away from a human login account and gives file ownership a clear boundary.
$ sudo install -d -o inventory-api -g inventory-api -m 0755 /opt/inventory-api
$ sudo install -d -m 0755 /etc/inventory-api
const http = require('node:http'); const host = process.env.HOST || '127.0.0.1'; const port = Number.parseInt(process.env.PORT || '3000', 10); const server = http.createServer((request, response) => { response.writeHead(200, { 'content-type': 'text/plain' }); response.end('Inventory API is running\n'); }); function shutdown(signal) { console.log(`Received ${signal}, closing HTTP server`); server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 8000).unref(); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); server.listen(port, host, () => { console.log(`Inventory API listening on http://${host}:${port}/`); });
Use the real application file when the service already exists. The process must stay in the foreground so systemd can supervise it directly.
$ sudo chown -R inventory-api:inventory-api /opt/inventory-api
NODE_ENV=production HOST=127.0.0.1 PORT=3000
Store passwords, API tokens, and private keys in a protected root-owned file with stricter permissions, not as inline Environment= values inside the unit file.
[Unit] Description=Inventory API Node.js service After=network.target [Service] Type=exec User=inventory-api Group=inventory-api WorkingDirectory=/opt/inventory-api EnvironmentFile=/etc/inventory-api/inventory-api.env ExecStart=/usr/bin/node /opt/inventory-api/server.js Restart=on-failure RestartSec=5 KillSignal=SIGTERM TimeoutStopSec=15 SyslogIdentifier=inventory-api [Install] WantedBy=multi-user.target
Type=exec reports startup failure when systemd cannot invoke /usr/bin/node. Restart=on-failure restarts unexpected exits, while KillSignal=SIGTERM gives the Node.js shutdown handler a normal stop request.
Tool: systemd Unit Generator
$ sudo systemd-analyze verify /etc/systemd/system/inventory-api.service
No output means systemd-analyze accepted the unit syntax and referenced directives.
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now inventory-api.service Created symlink '/etc/systemd/system/multi-user.target.wants/inventory-api.service' -> '/etc/systemd/system/inventory-api.service'.
enable creates the boot-time install link, and --now starts the service in the same operation.
$ systemctl show inventory-api.service -p FragmentPath -p LoadState -p ActiveState -p SubState -p UnitFileState LoadState=loaded ActiveState=active SubState=running FragmentPath=/etc/systemd/system/inventory-api.service UnitFileState=enabled
$ curl --retry 5 --retry-connrefused --retry-delay 1 --silent http://127.0.0.1:3000/ Inventory API is running
The retry options cover the short gap between systemd invoking node and the HTTP listener accepting connections.
$ sudo systemctl restart inventory-api.service
A restart stops the running Node.js process and starts a new one, so use a maintenance window when clients already depend on the endpoint.
$ curl --retry 5 --retry-connrefused --retry-delay 1 --silent http://127.0.0.1:3000/ Inventory API is running
$ sudo journalctl -u inventory-api.service -n 8 --no-pager Jun 28 00:33:58 app.example.net inventory-api[151]: Inventory API listening on http://127.0.0.1:3000/ Jun 28 00:33:58 app.example.net systemd[1]: Stopping inventory-api.service - Inventory API Node.js service... Jun 28 00:33:58 app.example.net inventory-api[151]: Received SIGTERM, closing HTTP server Jun 28 00:33:58 app.example.net systemd[1]: inventory-api.service: Deactivated successfully. Jun 28 00:33:58 app.example.net systemd[1]: Stopped inventory-api.service - Inventory API Node.js service. Jun 28 00:33:58 app.example.net systemd[1]: Starting inventory-api.service - Inventory API Node.js service... Jun 28 00:33:58 app.example.net systemd[1]: Started inventory-api.service - Inventory API Node.js service. Jun 28 00:33:58 app.example.net inventory-api[164]: Inventory API listening on http://127.0.0.1:3000/