Process Management with Systemd for Node.js: The Production Supervisor You Already Have
Every VM-deployed Node.js service eventually crashes and stays down until someone notices. Systemd gives you automatic restart, structured logging, resource limits, and zero-downtime deploys using a supervisor that ships with every Linux distro. Here is the exact service file and deployment workflow your app needs.
You deployed your Node.js API to a VM. You wrote a simple node server.js in a tmux session, or maybe you dropped a quick npm start into a cron job that checks if port 3000 is listening. Everyone does this at least once. It works fine for weeks. Then the process hits a file-descriptor limit, or the garbage collector triggers a V8 segfault on a memory corruption edge case you will never reproduce, or a transient ENOMEM kills the process during a traffic spike. The process exits. The port stays closed. Nobody notices until the monitoring alert fires at 3 AM, and by then your users have been staring at a blank page for four hours.
The solution is not a bigger VM, a better monitoring tool, or a Kubernetes cluster. The solution is a supervisor that already ships with every Linux distribution: systemd. It restarts your process when it dies, captures its logs before they vanish, limits its resource usage so a single service cannot take down the machine, and supports zero-downtime reloads through socket activation. And the whole thing is a 30-line text file.
This post walks through the exact systemd service unit your Node.js app needs, every option that matters, the logging setup that finally gets structured JSON into a searchable backend, and the deployment workflow that replaces tmux with systemctl.
The unit file that does the job
Most systemd tutorials ship something close to this minimal unit:
[Unit]
Description=My Node.js API
After=network.target
[Service]
ExecStart=/usr/bin/node /opt/myapp/dist/server.js
Restart=always
User=nodeapp
[Install]
WantedBy=multi-user.target
This is better than nothing, but it leaves the runtime unmanaged, the logs unstructured, and the failure modes unguarded. Here is the version that actually survives production:
[Unit]
Description=My Node.js API
Documentation=https://github.com/org/myapp
After=network.target
Wants=network-online.target
[Service]
Type=notify
ExecStart=/usr/bin/node /opt/myapp/dist/server.js
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
TimeoutStopSec=30
User=nodeapp
Group=nodeapp
WorkingDirectory=/opt/myapp
Environment=NODE_ENV=production
EnvironmentFile=/opt/myapp/.env
LimitNOFILE=65536
LimitNPROC=4096
MemoryMax=512M
CPUQuota=200%
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Every line here earns its keep. Let me explain the choices.
Type=notify
Node.js does not ship a native sd_notify implementation, but a few npm packages wrap the systemd notification protocol. The most common is sd-notify.
// server.js
import { createServer } from 'node:http';
import notify from 'sd-notify';
const server = createServer((req, res) => {
res.end('ok');
});
server.listen(3000, () => {
// Tell systemd: we are ready.
notify.ready();
// Tell systemd: we support reload.
notify.status('Server is running on port 3000');
});
process.on('SIGINT', () => {
notify.status('Shutting down...');
server.close(() => process.exit(0));
});
When Type=notify is set, systemd blocks the After targets (like network-online.target) until the process calls sd_notify("READY=1"). This means a dependent service, like a reverse proxy or health check, starts only after your Node.js app is actually listening. No more race conditions where nginx starts proxying before Express is ready to accept connections.
Without this, your deployment script has to add a manual sleep or a polling loop. With it, the init system handles ordering for free.
RestartSec=5
The Restart=always line without a RestartSec defaults to 100ms. If your process crashes on startup due to a transient condition (say, the database is still migrating), it will enter a tight crash-restart loop that burns CPU and floods the journal. A RestartSec=5 inserts a five-second pause between restart attempts, and systemd doubles this on successive failures (exponential backoff built in).
TimeoutStopSec=30
When you run systemctl stop myapp or systemctl restart myapp, systemd sends SIGTERM to the main process, then waits for it to exit. If the process does not exit within TimeoutStopSec, systemd escalates to SIGKILL. A 30-second window is enough for a Node.js server to drain its active connections and close gracefully. Set it too short (default is 90 seconds on many distros, which is too long) and the kill signal arrives while the process is still trying to finish a database query.
LimitNOFILE and LimitNPROC
A Node.js HTTP server opens a file descriptor for every socket connection. Under load, a single process can easily exhaust the default 1024-file-descriptor limit, causing EMFILE errors that crash the server silently. LimitNOFILE=65536 raises the soft limit to a number that handles thousands of concurrent connections. LimitNPROC=4096 prevents a runaway worker-thread pool from forking processes until the OOM killer steps in.
These are the same limits you would set in a Docker container with --ulimit, but systemd handles them at the OS level, without container overhead.
MemoryMax and CPUQuota
MemoryMax=512M tells systemd to enforce a cgroup memory limit on the process group. If your Node.js app enters a memory leak cycle, it hits the limit and gets OOM-killed instead of starving other services on the same machine. Systemd then restarts it per the Restart policy.
CPUQuota=200% limits the process to two CPU cores. Without this, a single compute-heavy request (think image processing or PDF generation) can peg all available cores and degrade latency for every other service on the box.
These two options are the poor-man’s resource reservation. They are not as sophisticated as Kubernetes resource limits, but they are free, require no cluster, and work on a single $5 VM.
StandardOutput and StandardError
Setting both to journal sends stdout and stderr to systemd-journald instead of /var/log/syslog or a random file. This matters because a Node.js process that writes logs to stdout and then crashes leaves those logs in the void. With journald, every line is captured before the process disappears.
But raw stdout is just text. To get structured JSON into the journal, pipe your logs through a filter:
// logger.js
import pino from 'pino';
const transport = pino.transport({
target: 'pino-systemd',
options: {}
});
export const logger = pino(transport);
The pino-systemd transport formats each log line as a syslog-style message that journald parses into structured fields. You can then query with journalctl:
# Tail the logs for your service
journalctl -u myapp -f
# Show only error-level messages from the last hour
journalctl -u myapp -p err --since "1 hour ago"
# Export logs in JSON format for ingestion into your observability platform
journalctl -u myapp -o json --since "1 day ago" > /tmp/logs.json
This is the pipeline that gives you structured, searchable logs without a dedicated log agent. No Filebeat, no Fluentd, no sidecar container. Just systemd and a 10-line transport configuration.
Socket activation: zero-downtime restarts
The most common reason production HTTP requests fail during a deploy is the window between when the old process stops listening and the new process starts accepting connections. Socket activation closes that window.
The idea: systemd opens the TCP socket and holds it open, then passes the file descriptor to your application. When you restart the service, the socket stays open. There is no gap where the port is unbound.
Create two files. First, a socket unit:
# /etc/systemd/system/myapp.socket
[Unit]
Description=My Node.js API socket
[Socket]
ListenStream=3000
BindIPv6Only=both
[Install]
WantedBy=sockets.target
Then, modify the service unit to pull the socket from systemd:
[Service]
Type=notify
ExecStart=/usr/bin/node /opt/myapp/dist/server.js
Restart=always
RestartSec=5
TimeoutStopSec=30
# This tells systemd to pass the socket FD
Sockets=myapp.socket
# ... rest of the options from above
And update your server code to accept the pre-opened socket:
// server.js
import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import notify from 'sd-notify';
const require = createRequire(import.meta.url);
const server = createServer((req, res) => {
res.end('ok');
});
// Listen on the socket passed by systemd, or fall back to a direct port.
const socket = process.env.LISTEN_FDS
? { fd: 3 } // systemd passes the first socket as fd 3
: { port: 3000 };
server.listen(socket, () => {
notify.ready();
});
Now when you deploy a new version:
# Replace the app files
rsync -avz --delete dist/ nodeapp@host:/opt/myapp/dist/
# Reload the service -- the socket never closes
systemctl reload myapp
# Or for a full restart that keeps the socket:
systemctl restart myapp
During the reload, the old process receives SIGHUP (from ExecReload), finishes its in-flight requests, and exits. The new process starts accepting on the same socket handle that systemd has held open the entire time. Clients see zero connection refusals.
This is the same mechanism that nginx uses for its famous “hot reload.” It costs nothing in system resources (the socket is just an inode) and eliminates an entire class of deploy-related errors.
The deployment workflow
Here is the complete deployment flow that replaces rsync && ssh 'tmux new -d "node server.js"' with a proper lifecycle:
#!/usr/bin/env bash
set -euo pipefail
HOST=${1:?Usage: $0 <host>}
APP=myapp
REMOTE_DIR=/opt/$APP
echo "Building..."
npm ci --omit=dev
npm run build
echo "Syncing files..."
rsync -avz --delete \
--exclude node_modules \
--exclude .git \
./dist/ \
package.json \
node_modules/ \
$HOST:$REMOTE_DIR/
echo "Installing dependencies on host..."
ssh $HOST "cd $REMOTE_DIR && npm ci --omit=dev"
echo "Reloading service..."
ssh $HOST "systemctl reload $APP"
echo "Checking health..."
for i in $(seq 1 10); do
if curl -sf http://$HOST:3000/health > /dev/null 2>&1; then
echo "Health check passed."
exit 0
fi
sleep 2
done
echo "Health check failed after 20 seconds."
exit 1
This script runs from CI after tests pass. It builds the app, syncs only what changed, installs production dependencies, reloads the systemd service, and then polls the health endpoint until it responds. If the health check fails, the script exits non-zero and the deploy is rolled back by redeploying the previous build.
The key insight: systemctl reload triggers ExecReload (the kill -HUP $MAINPID), which the Node.js process catches to drain connections and restart. But if Type=notify is set, the reload waits for the READY=1 notification from the new process before declaring success. If the new process fails to start (say, a syntax error in the new deploy), systemd detects the missing notification and rolls back to the old process automatically.
This is the “automatic rollback on failed start” behavior that teams usually pay a container orchestrator for.
Monitoring: the watchdog integration
Systemd has a built-in watchdog that kills and restarts processes that stop responding. You activate it with two lines:
[Service]
WatchdogSec=30
And from your Node.js code, send a periodic heartbeat:
import notify from 'sd-notify';
// Tell systemd "I am alive" every 10 seconds.
setInterval(() => {
notify.watchdog();
}, 10_000);
If the process hangs (event loop blocked, deadlock on a database connection, infinite loop in a hot path), it stops sending watchdog notifications. After 30 seconds (two missed heartbeats, since systemd allows one missed interval), systemd kills the process with SIGABRT and restarts it. The journal captures the core dump so you can analyze the stuck state post-mortem.
This is liveness probing without an external health checker. The init system is the monitor.
What to log, what not to log
A well-configured systemd-based Node.js app produces logs in three buckets:
Application events (journald via structured logging): Request lifecycle, database query latency, error traces, startup and shutdown. These go through pino-systemd so they are queryable by severity, service name, and correlation ID.
Stdout/stderr (journald): Anything the process writes to stdout that bypasses the logger (uncaught exceptions, third-party module noise, Node.js internal warnings). These go to the journal automatically because StandardOutput=journal is set.
Core dumps (journald or coredumpctl): When a process crashes with a signal, systemd captures the core dump if ulimit -c unlimited is set. You can inspect it later:
# List recent core dumps
coredumpctl list
# Inspect a specific dump
coredumpctl info myapp
# Run a debugger on the dump
coredumpctl debug myapp
If you forward the journal to a remote log aggregator, use systemd-journal-remote or a lightweight forwarder like journald-cloudwatch-logs. Do not tail journalctl -f in a sidecar script; that introduces a single point of failure that loses logs if the sidecar dies before the main process.
The takeaway
If your Node.js app runs on a VM or a bare-metal server, systemd is the supervisor you are looking for but have not configured correctly. The 30-line unit file above replaces tmux sessions, cron-based restart scripts, manual nohup invocations, and the parade of fragile “process managers” that people install because they do not know systemd can do all of it.
The concrete changes to make on your next deploy:
- Write the service unit with
Type=notify,RestartSec=5,TimeoutStopSec=30, file descriptor limits, and memory caps. - Integrate
sd-notifyinto your server entry point so systemd knows when your app is actually ready. - Set
StandardOutput=journaland pipe your structured logger through a systemd-compatible transport. - Add a socket unit if you want zero-downtime restarts.
- Wire
systemctl reloadinto your CI deployment script instead of killing and restarting the process manually.
Every one of these changes is free, tested, and documented already inside the systemd.service(5) man page. The only thing stopping you from using them is that nobody told you they exist. Now you know.
A note from Yojji
The difference between a VM that quietly runs for months and one that silently stops serving traffic at 2 AM is not a bigger cluster or a more expensive monitoring stack. It is the boring, unglamorous practice of writing a proper init configuration, setting resource limits, and wiring the logging pipeline before the first outage. That kind of disciplined infrastructure work is exactly what Yojji has been doing for clients since 2016, building and running Node.js services that survive real production load.
Yojji is an international custom software development company with offices in Europe, the US, and the UK. Their teams focus on the JavaScript ecosystem, cloud platforms, and microservices architectures, providing dedicated senior engineers and full-cycle product development from discovery through DevOps. If you would rather ship infrastructure that stays running than learn the file-descriptor lesson at 3 AM on a Saturday, Yojji is worth a conversation.