A production Node.js process needs more than a command in an SSH session. It should start after reboot, restart after an unexpected failure, run as a restricted account, receive shutdown signals correctly, and send logs somewhere operators can inspect. On Ubuntu, systemd provides those controls without adding a JavaScript process manager.

This guide turns a built Node.js API into a hardened system service. It uses an absolute runtime path, a dedicated user, environment-file permissions, automatic recovery, journal logging, graceful shutdown, and filesystem restrictions. The same structure works for Express, Fastify, NestJS, and most long-running Node applications.

Choose the deployment layout

The example uses these paths:

/srv/node-api/             application releases
/srv/node-api/current/     active release
/srv/node-api/shared/      writable uploads or generated files
/etc/node-api.env          production environment variables
/etc/systemd/system/node-api.service

Keep application code outside a human user's home directory. A dedicated service identity makes ownership and audit rules clear:

sudo useradd --system \
  --home /srv/node-api \
  --shell /usr/sbin/nologin \
  node-api

sudo mkdir -p /srv/node-api/current /srv/node-api/shared sudo chown -R node-api:node-api /srv/node-api

The service user does not need an interactive shell or sudo. Deploy from a separate account or CI job, then grant the runtime user only the read and write access the application actually needs.

Build before starting the service

Install from the lockfile and produce the release artifact during deployment:

cd /srv/node-api/current
npm ci
npm run build
npm prune --omit=dev

/usr/bin/node --version test -f dist/server.js

Do not put npm install or a build command in ExecStart. Service startup should be fast and deterministic. If compilation fails, the currently running release should remain untouched.

Store environment values outside the unit

Create a root-owned file:

sudo install -o root -g node-api -m 0640 /dev/null /etc/node-api.env
sudoedit /etc/node-api.env
PORT=3000
DATABASE_URL=postgresql://app:replace-me@127.0.0.1:5432/app
LOG_LEVEL=info

Never commit this file. Remember that environment variables are configuration, not a complete secret-management system: privileged operators and some diagnostic interfaces may still access them. Use a dedicated secret manager when your threat model requires centralized rotation and auditing.

Create the systemd unit

Create /etc/systemd/system/node-api.service:

[Unit]
Description=Node API service
Wants=network-online.target
After=network-online.target

[Service] Type=simple User=node-api Group=node-api WorkingDirectory=/srv/node-api/current Environment=NODE_ENV=production EnvironmentFile=/etc/node-api.env ExecStart=/usr/bin/node /srv/node-api/current/dist/server.js

Restart=on-failure RestartSec=5s TimeoutStopSec=30s KillSignal=SIGTERM

NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/srv/node-api/shared CapabilityBoundingSet= RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 LockPersonality=true

[Install] WantedBy=multi-user.target

Type=simple is appropriate when Node remains in the foreground. Do not add --daemon or fork a detached child. Restart=on-failure recovers from crashes but does not restart the application after a clean intentional exit. The delay prevents a broken release from creating a tight restart loop.

ProtectSystem=strict makes the filesystem read-only from the service's view, with ReadWritePaths opening only the shared directory. Add every legitimate writable path explicitly, such as an application cache. If your app writes inside its code directory today, move that data before enabling this restriction.

Why absolute paths matter

system services do not inherit your interactive shell setup. A Node binary installed through a per-user version manager may not exist for the service account. Check the production path with command -v node and put that absolute path in ExecStart.

For predictable upgrades, install a supported Node release system-wide or place a versioned runtime beside the release and update the unit during deployment. Do not depend on aliases, shell profile files, or an implicit PATH.

Enable and start the service

sudo systemd-analyze verify /etc/systemd/system/node-api.service
sudo systemctl daemon-reload
sudo systemctl enable --now node-api.service
sudo systemctl status node-api.service --no-pager

daemon-reload tells systemd to reread unit files. enable configures boot-time activation; --now also starts it immediately. If startup fails, inspect the journal before changing the unit.

Make shutdown graceful in Node.js

On stop or restart, systemd sends the configured termination signal and waits up to TimeoutStopSec. Handle it so the server stops accepting requests and closes dependencies:

const server = app.listen(process.env.PORT || 3000)

let shuttingDown = false

async function shutdown(signal) { if (shuttingDown) return shuttingDown = true console.log(JSON.stringify({ event: 'shutdown', signal }))

const forceExit = setTimeout(() => process.exit(1), 25_000) forceExit.unref()

server.close(async (error) => { try { await databasePool.end() process.exit(error ? 1 : 0) } catch (closeError) { console.error(closeError) process.exit(1) } }) }

process.on('SIGTERM', () => void shutdown('SIGTERM')) process.on('SIGINT', () => void shutdown('SIGINT'))

Set the application's forced-exit timer shorter than the systemd timeout. Test shutdown under load; long-lived requests, WebSockets, queues, and database transactions need explicit policies.

Read logs with journalctl

stdout and stderr are captured by the journal by default:

sudo journalctl -u node-api.service -n 100 --no-pager
sudo journalctl -u node-api.service -f
sudo journalctl -u node-api.service --since "30 minutes ago"
sudo journalctl -u node-api.service -p warning

Emit one structured JSON object per line where practical, and never log tokens, passwords, session cookies, or full authorization headers. Configure journal retention for the server's available disk and forward important logs if the host can be replaced.

Deploy a new release safely

Build and test the new release before switching current. Then reload the service manager only when the unit changed and restart the app:

sudo systemctl daemon-reload
sudo systemctl restart node-api.service
sudo systemctl is-active --quiet node-api.service
curl --fail --silent http://127.0.0.1:3000/health

A single service restart creates a brief availability gap. For zero-downtime deployments, run multiple instances behind a reverse proxy or load balancer, drain one instance at a time, and make database migrations backward compatible across both releases.

Audit the sandbox before production

sudo systemd-analyze security node-api.service
sudo -u node-api test -r /srv/node-api/current/dist/server.js
sudo -u node-api test -w /srv/node-api/shared

The security score is a review aid, not proof of safety. Add restrictions incrementally and run application tests after each change. Some hardening options can break native modules, child processes, filesystem access, or runtime compilation. Avoid copying a maximum-hardening template without understanding the application.

Troubleshooting common failures

Status 203/EXEC

The executable path is wrong or not executable. Verify ExecStart with ls -l /usr/bin/node and run the exact command as the service user.

The app cannot read environment variables

Check EnvironmentFile spelling, permissions, and syntax. Values are not processed by a full interactive shell, so avoid shell substitutions. Restart after changing the file.

Read-only filesystem errors

The sandbox is working. Move mutable data to a dedicated directory and add that exact path to ReadWritePaths. Do not make the whole application tree writable.

The service keeps restarting

Use systemctl status and journalctl to find the first failure. After fixing it, sudo systemctl reset-failed node-api clears a reached start limit.

It runs manually but not under systemd

Compare the user, working directory, runtime path, environment, and file permissions. Reproduce with sudo -u node-api /usr/bin/node /srv/node-api/current/dist/server.js.

Production checklist

  • Run the app as a dedicated non-login user.
  • Use an absolute, supported Node.js runtime path.
  • Build and test releases before restarting production.
  • Protect the environment file and rotate exposed secrets.
  • Use Restart=on-failure with a restart delay.
  • Handle SIGTERM and close servers and database pools.
  • Restrict filesystem writes to explicit data directories.
  • Verify the unit, health endpoint, logs, and boot activation.
  • Test rollback and avoid incompatible database migrations.

Official sources