
Run Node.js with systemd on Ubuntu: Production Guide
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-failurewith a restart delay. - Handle
SIGTERMand 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.