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:

  • Load Balancer Draining: Put nginx or HAProxy in front of multiple VMs. Take one VM out of the rotation, restart systemd, wait for health check, put it back. This is the standard approach for fleets.
  • Blue/Green Systemd Services: On a single VM, run two service files: 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


    Published on agentic.dev.