App Deployment·8 min read·

Zero-Downtime Node Restarts with systemd Sockets

Keep the listening socket in systemd so Node restarts do not drop the bind, and nginx clients stop seeing connection refused during deploys.

NB

Netbay Engineering

Netbay Engineering

On this page

systemctl restart nodeapp is a brief outage. systemd kills the process, the TCP port disappears, nginx gets connection refused, and a handful of clients see 502 until the new Node process bind()s again. On a quiet site you never notice. On a busy API you notice every deploy. The fix is not a second process manager. The fix is to let systemd own the listening socket so the port never vanishes while the service restarts.

Socket activation is older than Node. systemd opens the socket, passes the file descriptor to the process as fd 3, and keeps that socket open across ExecStart cycles. nginx keeps a connection to an address that still exists. New accepts wait until the new process is ready. That is not a magical drain of in-flight requests, and this post will not pretend it is. It is the difference between connection refused and a short queue.

Split the unit into a socket and a service

A .socket unit describes the bind. A .service unit describes the process. Enable the socket, not the service; systemd starts the service on the first accept, or you can still start it by hand. ListenStream can be a TCP address or a Unix path. Unix sockets keep the app off the TCP stack entirely, which is the better default when nginx is on the same host.

ini
# /etc/systemd/system/nodeapp.socket
[Unit]
Description=Node HTTP listen socket
PartOf=nodeapp.service

[Socket]
ListenStream=/run/nodeapp.sock
SocketUser=www-data
SocketGroup=www-data
SocketMode=0660
NoDelay=true
Accept=no

[Install]
WantedBy=sockets.target

Accept=no means systemd passes the listening fd, and Node calls accept() itself. Accept=yes would spawn a new process per connection, which is wrong for an HTTP server. SocketUser must be the user nginx runs as so the proxy can connect. The Node process still runs as nodeapp; it inherits the already-open fd and does not need to chown the path.

ini
# /etc/systemd/system/nodeapp.service
[Unit]
Description=Node HTTP server
Requires=nodeapp.socket
After=nodeapp.socket

[Service]
Type=simple
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/app
EnvironmentFile=/etc/nodeapp/app.env
ExecStart=/usr/bin/node /srv/app/server.js
Restart=on-failure
RestartSec=2
NonBlocking=true
KillSignal=SIGTERM
TimeoutStopSec=25

[Install]
WantedBy=multi-user.target

NonBlocking=true matches how Node wants the fd. Requires= and After= keep the service from starting if the socket unit is missing. Enable the socket: systemctl enable --now nodeapp.socket. Then systemctl start nodeapp.service if you want the process up before the first request.

Listen on the inherited descriptor

systemd sets LISTEN_FDS to the count of passed sockets and LISTEN_PID to the pid that should consume them. The first fd is always 3. If LISTEN_PID does not match process.pid, ignore the fd; it was inherited by accident. Fall back to a normal listen only in development.

javascript
const http = require('http');
const fsStart = 3;
const server = http.createServer(function (req, res) {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('ok\n');
});
function shutdown() {
  server.close(function () { process.exit(0); });
  setTimeout(function () { process.exit(1); }, 20000).unref();
}
process.on('SIGTERM', shutdown);
const fds = Number(process.env.LISTEN_FDS || 0);
const pidMatch = process.env.LISTEN_PID === String(process.pid);
if (fds >= 1 && pidMatch) {
  server.listen({ fd: fsStart });
} else {
  server.listen(3000, '127.0.0.1');
}

Point nginx at the Unix socket with proxy_pass http://unix:/run/nodeapp.sock:; including the trailing semicolon after the path. curl --unix-socket /run/nodeapp.sock http://localhost/ is the host-local health check that does not go through TLS.

What zero-downtime actually means here

While the old process drains, the socket still accepts. The new process is not running yet, so new connections sit in the accept queue until ExecStart is up. Keep the app boot fast: no giant migrations in the request path, no synchronous compiles. TimeoutStopSec must cover the drain. If the old process ignores SIGTERM, systemd SIGKILLs it and in-flight work dies; the socket still never disappears, which is why 502s become stalls instead of refusals.

This is not blue-green. A second port, a second unit, and an nginx upstream swap can hide boot time entirely. That is more moving parts than a single VPS usually wants. Socket activation is the one-unit version. Measure boot with systemd-analyze and with time to first journal line that says listening. If startup is two seconds, users wait two seconds. If startup is thirty because npm run start rebuilds, fix the start command instead of adding another supervisor.

systemctl restart nodeapp.service now restarts the process and leaves nodeapp.socket running. Do not restart the socket unit unless you intend to drop the bind. systemctl stop nodeapp.socket is how you take the app off the air on purpose.

The socket outlives the process across restart nginx proxy_pass unix nodeapp.socket /run/nodeapp.sock old node (draining) new node (fd 3) restart service, not socket accept queue holds; the bind never vanishes High-Speed SSD keeps cold start short on Lucknow VPS

The takeaway: systemd holds the socket, Node inherits fd 3, nginx talks to a path that never goes away, and restart means SIGTERM plus a new ExecStart. That is zero-downtime at the bind layer, which is the layer that was producing 502s.

Try the socket unit on a Netbay Ubuntu 24.04 VPS and restart under traffic — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance