Running Node.js in Production with systemd: Zero-Downtime Deployments
In 2026, the industry obsession with Kubernetes hasn't faded, but the pendulum is swinging back towards simplicity. I've spent the last year deploying autonomous AI agents across a fleet of EC2 instances, and frankly, orchestrating a single Node.js service with K8s often feels like using a sledgehammer to crack a nut. For edge nodes, cost-sensitive workloads, or dedicated agent runners, a hardened VM managed by systemd is still the gold standard for reliability.
However, the classic complaint about systemd persists: "How do I deploy without dropping requests?" If you're running a stateful AI agent or a webhook listener, a blunt systemctl restart means downtime. In this guide, I'll walk you through configuring Node.js 24 LTS with systemd to achieve graceful shutdowns and near-zero downtime deployments, without introducing the complexity of a container orchestration layer.
The Hardened Service Unit File
The foundation of a stable production environment isn't your code; it's the process manager. In the systemd ecosystem, this is the unit file. Most tutorials give you a bare-bones configuration. In 2026, security defaults have shifted, and we need to lock down the service context explicitly.
Create a file at /etc/systemd/system/agent.service. We aren't just starting Node; we're defining a security boundary.
[Unit]
Description=Autonomous AI Agent Runner
Documentation=https://devaa-io.github.io/agentic-dev/
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=notify
ExecStart=/usr/bin/node /opt/agent/dist/index.js
ExecReload=/bin/kill -s SIGTERM $MAINPID
WorkingDirectory=/opt/agent
User=agent-user
Group=agent-user
Environment & Limits
Environment=NODE_ENV=production
Environment=PORT=3000
LimitNOFILE=65535
LimitNPROC=4096
Security Hardening (Critical for 2026)
NoNewPrivileges=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/opt/agent/logs /opt/agent/data
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
Restart Policy
Restart=on-failure
RestartSec=5s
Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=agent
[Install]
WantedBy=multi-user.target
There are three critical details here that separate production configs from dev setups. First, Type=notify allows Node.js to signal systemd when it's truly ready to accept traffic using the sd-notify package, preventing race conditions during boot. Second, the security directives like ProtectSystem=strict and NoNewPrivileges=true ensure that even if your agent is compromised via a prompt injection or dependency vulnerability, the attacker cannot write to the root filesystem or escalate privileges. Third, ExecReload is mapped to SIGTERM. This is the hook we'll use for graceful shutdowns.
Notice I specified a dedicated agent-user. Never run production Node.js services as root. Create this user with useradd -r -s /bin/false agent-user to ensure it cannot login interactively.
Implementing Graceful Shutdowns in Node.js
Systemd can send the signals, but Node.js must listen for them. A "zero-downtime" deployment is actually a misnomer; what we really want is "zero dropped connections." When systemd sends SIGTERM during a restart or reload, your application must stop accepting new connections, finish processing active requests, and then exit.
In Node.js 24, the cluster module remains the most robust way to handle this on a single VM, allowing us to spawn workers that can be restarted individually. However, for many AI agents that maintain in-memory state (like vector caches or active conversation contexts), a single-process model with careful signal handling is often preferred to avoid state synchronization issues.
Here is the production-ready bootstrap code (src/index.ts):
import { createServer } from 'http';
import { config } from './config';
const server = createServer(handleRequest);
let isShuttingDown = false;
function handleClose() {
if (isShuttingDown) return;
isShuttingDown = true;
console.log('SIGTERM received: Starting graceful shutdown...');
// Stop accepting new connections
server.close(() => {
console.log('HTTP server closed.');
// Close DB connections, flush logs, etc.
cleanupResources().then(() => {
process.exit(0);
}).catch((err) => {
console.error('Error during cleanup:', err);
process.exit(1);
});
});
// Force kill if graceful shutdown takes too long
setTimeout(() => {
console.error('Forced shutdown due to timeout.');
process.exit(1);
}, 30000); // 30 second timeout
}
process.on('SIGTERM', handleClose);
process.on('SIGINT', handleClose);
server.listen(config.port, () => {
console.log(Agent listening on port ${config.port});
// If using Type=notify in systemd, send readiness now
// import { notify } from 'sd-notify';
// notify.ready();
});
The key here is the server.close() method. It stops the server from accepting new connections but keeps the event loop alive until existing connections are drained. The setTimeout acts as a safety valve; if your AI agent gets stuck in an infinite loop during cleanup, systemd won't wait forever. The RestartSec=5s in the unit file gives the process enough time to exit cleanly before systemd attempts to start it again.
Without this handler, a systemctl restart kills the process immediately (SIGKILL after timeout), dropping any active webhook payloads or streaming responses mid-chunk.
The Deployment Strategy: Reload vs. Restart
Here is where the tradeoffs become real. In a Kubernetes environment, you get rolling updates out of the box. With systemd, you have to orchestrate the swap yourself.
If you are deploying code changes, systemctl reload is insufficient because it only sends the signal; it doesn't pull new code. You need to restart the process. To achieve true zero-downtime during a binary update, you cannot rely on a single service instance. You have two options:
agent-blue.service and agent-green.service. They listen on different ports (e.g., 3001 and 3002). Your nginx upstream points to the "active" port. To deploy, start the inactive service with the new code, verify health, switch nginx, then stop the old service.For most single-VM agent runners, I recommend the Load Balancer Draining approach if you have multiple nodes. If you are on a single node, the Graceful Shutdown pattern above reduces downtime to milliseconds-just the time it takes to start the new process.
To automate this safely, I use a simple deployment script that leverages systemd's health checking:
#!/bin/bash
set -e
echo "Deploying new version..."
1. Upload new build
rsync -avz ./dist/ user@ec2-instance:/opt/agent/dist/
2. Trigger graceful restart
systemd will send SIGTERM, Node drains connections, then restarts
sudo systemctl restart agent.service
3. Verify health
for i in {1..10}; do
if curl -f http://localhost:3000/health; then
echo "Deployment successful."
exit 0
fi
sleep 2
done
echo "Health check failed! Rolling back..."
Insert rollback logic here
exit 1
This script assumes you accept a brief moment where the process is swapping. For 95% of AI agent workloads (background processing, async tasks), this is indistinguishable from zero-downtime. For synchronous HTTP APIs, the graceful shutdown ensures no 502 errors are returned to the client during the swap.
Observability with journald
One of systemd's underrated features is journald. In 2026, shipping logs to S3 or Elasticsearch is standard, but you shouldn't bypass the local log buffer. Writing to stdout and letting systemd capture it (as configured in our unit file with StandardOutput=journal) is superior to writing to static files like error.log.
Why? Because journald handles log rotation automatically and preserves metadata. You can query logs by service unit, priority, or time range without parsing text files.
To view your agent's logs in real-time:
journalctl -u agent.service -f
To see logs from the last reboot only:
journalctl -u agent.service -b
For production monitoring, I recommend installing systemd-journal-remote to forward these logs to a central aggregation server, or using a lightweight shipper like FluentBit configured to read from the journal socket. Avoid parsing raw text files; it's 2026, let the init system handle log lifecycle management.
Additionally, use systemctl status agent.service to quickly see if the service is active, failed, or restarting. For AI agents, memory leaks are common due to unbounded context windows. Set MemoryMax=1G in the [Service] section to have systemd OOM-kill the service if it exceeds limits, triggering an automatic restart before it takes down the whole VM.
Key Takeaways
- Security First: Use systemd security directives (
ProtectSystem,NoNewPrivileges) to sandbox your Node.js process; don't rely solely on application-level security. - Graceful Shutdowns: Implement
SIGTERMhandlers in Node.js to drain connections before exit; this prevents dropped requests during restarts. - Orchestration Reality: systemd alone doesn't do rolling updates; pair it with a load balancer or Blue/Green service pattern for true zero-downtime code swaps.
- Logging: Leverage
journaldinstead of file logging for better rotation, metadata, and integration with modern observability stacks.
Published on agentic.dev.