DEV Community

Cover image for Stop Booting Daemons Just to Listen: Practical systemd.socket Activation on Linux
Lyra
Lyra

Posted on

Stop Booting Daemons Just to Listen: Practical systemd.socket Activation on Linux

You have a small internal API that gets hit a few times a day. Or a debug endpoint. Or a homelab tool that only matters when someone actually connects. The usual pattern is still:

# enable the service at boot so the port is open
systemctl enable --now myapp.service
Enter fullscreen mode Exit fullscreen mode

That works. It also means the process, its language runtime, and its memory footprint sit warm forever — even at 3 a.m. when nobody is talking to it. Worse, if the daemon crashes hard enough to lose its listening socket, clients get Connection refused until something restarts it.

systemd already solved this. A .socket unit owns the listening file descriptor. The matching .service starts when traffic arrives (or in parallel at boot, sharing the pre-opened sockets). Same journal, cgroup, and sandbox knobs you already use for services.

This guide is operational. Behavior and defaults come from systemd.socket(5), systemd.service(5), sd_listen_fds(3), systemd-socket-proxyd(8), and stock units on systemd 257 (verified on Debian 13).

What a socket unit is (and is not)

Piece Job
.socket unit Create and listen on TCP/UDP/UNIX/FIFO/… FDs; start a service on traffic
Matching .service Accept and handle work (Accept=no) or one connection (Accept=yes)
sd_listen_fds(3) Native protocol: FDs start at 3, counted by $LISTEN_FDS
StandardInput=socket inetd-style: one accepted connection on stdin
.path Filesystem path activation (inotify), not network connections
.timer Time-based activation

A socket unit does not replace:

  • services that must do heavy work before they can usefully accept clients (unless you also start them explicitly),
  • full reverse proxies / L7 load balancers,
  • apps that insist on binding and owning the port themselves with no FD-passing support (use systemd-socket-proxyd or fix the app).

It does replace most “always-on listener for rarely used ports” and pairs naturally with on-demand tools.

Mental model

  1. You install foo.socket and foo.service (same basename by default).
  2. You enable and start the socket unit, not necessarily the service.
  3. systemd binds and listens before (or without) the service process.
  4. On incoming traffic, systemd starts the service and passes listening FDs (or one accepted connection).
  5. If the service exits and the socket unit stays up, the port keeps listening. The next connection can start the service again.
  6. There is no implicit WantedBy= from socket → service. The service can still be started alone; if you need “service requires its socket”, add an explicit Requires= / After= on the service (or use Sockets=).

That last point is why ssh.socket + ssh.service style pairs are common: the socket is the always-on edge; the daemon is optional until needed (or started in parallel sharing the FD).

Prerequisites

systemctl --version | head -n 1
# systemd 257 (socket activation is long-standing; details below note version gates)

man systemd.socket
man sd_listen_fds
systemctl list-units --type=socket --all
Enter fullscreen mode Exit fullscreen mode

On a typical host you already have stock examples:

  • ssh.socket — ListenStream=22, Accept=no
  • dbus.socket — ListenStream=/run/dbus/system_bus_socket
  • systemd-hostnamed.socket — Varlink UNIX socket + FileDescriptorName=varlink
  • systemd-journald.socket — multiple Listen* stanzas, PassCredentials=yes
systemctl cat ssh.socket
# [Socket]
# ListenStream=22
# Accept=no
Enter fullscreen mode Exit fullscreen mode

Accept=no vs Accept=yes

This is the fork in the road. Get it wrong and either naming or FD semantics will surprise you.

Accept=no (default) Accept=yes
What is passed The listening socket FD(s) One already-accepted connection FD
Service unit foo.service (single instance) Template foo@.service (one instance per connection)
App model Real daemon: accept() loop, or use passed listeners inetd-style: handle one client then exit
Performance First connection pays start cost; later ones reuse process Every connection pays start cost
Isolation One process sees all clients Each connection can be sandboxed separately
Datagram / FIFO Always single-service style (Accept= ignored) —
Env extras $LISTEN_FDS, $LISTEN_PID, … $REMOTE_ADDR, $REMOTE_PORT, $SO_COOKIE

From systemd.socket(5):

  • Performance-sensitive services prefer Accept=no.
  • Sporadic tools prefer Accept=yes (simpler code, stronger per-connection sandboxing).
  • With Accept=yes, set CollectMode=inactive-or-failed on the template so failed instances do not pile up in memory.

How FDs are passed (Accept=no)

Native protocol (sd_listen_fds(3)):

  • FDs start at SD_LISTEN_FDS_START = 3 (after stdin/stdout/stderr).
  • Count is in $LISTEN_FDS.
  • $LISTEN_PID must match the receiving process (since 259, $LISTEN_PIDFDID is also checked when set).
  • Optional $LISTEN_FDNAMES colon-separated names from FileDescriptorName= or defaults.
  • systemd sets FD_CLOEXEC on the passed FDs.
  • Do not shutdown(2) listening sockets you got with Accept=no — the manager keeps a duplicate and you would break it.
  • Do not unlink AF_UNIX paths the manager owns.

inetd-style alternative: StandardInput=socket in the service. Then $LISTEN_FDS is not set; the connection (or listener, depending on Accept) is stdin. That path is ideal for Accept=yes helpers.

Lab 1 — Per-connection echo (Accept=yes)

inetd-mode is the fastest way to prove socket activation without linking libsystemd.

sudo tee /etc/systemd/system/lyra-echo@.service >/dev/null <<'EOF'
[Unit]
Description=Lyra per-connection echo server
CollectMode=inactive-or-failed

[Service]
Type=oneshot
ExecStart=/usr/bin/bash -c 'printf "lyra-echo ready\\n"; cat'
StandardInput=socket
StandardOutput=socket
StandardError=journal
# Optional hardening for a toy service:
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
EOF

sudo tee /etc/systemd/system/lyra-echo.socket >/dev/null <<'EOF'
[Unit]
Description=Lyra echo socket (Accept=yes demo)

[Socket]
ListenStream=127.0.0.1:9099
Accept=yes
# Defaults: MaxConnections=64
# TriggerLimitIntervalSec=2s TriggerLimitBurst=200  (Accept=yes)

[Install]
WantedBy=sockets.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now lyra-echo.socket
systemctl status lyra-echo.socket --no-pager
Enter fullscreen mode Exit fullscreen mode

Test:

printf 'hello\n' | nc -q 1 127.0.0.1 9099
# lyra-echo ready
# hello

systemctl list-units 'lyra-echo@*' --all --no-pager
journalctl -u 'lyra-echo@*' -n 20 --no-pager
Enter fullscreen mode Exit fullscreen mode

What just happened:

  1. lyra-echo.socket bound 127.0.0.1:9099 and sat in listening.
  2. nc connected; systemd accept()’d and spawned lyra-echo@<conn>.service.
  3. The service saw the client on stdin/stdout, echoed, exited.
  4. The socket unit kept listening for the next client.

$REMOTE_ADDR / $REMOTE_PORT are set for IPv4/IPv6 accepts (CGI-style; see RFC 3875). Useful for logging:

ExecStart=/usr/bin/bash -c 'echo "peer=$REMOTE_ADDR:$REMOTE_PORT" >&2; cat'
Enter fullscreen mode Exit fullscreen mode

Lab 2 — Long-running listener (Accept=no + $LISTEN_FDS)

Now the production-shaped path: one service, listening FDs inherited.

sudo tee /usr/local/bin/lyra-listen-demo >/dev/null <<'EOF'
#!/usr/bin/env python3
"""Minimal Accept=no socket-activated server using $LISTEN_FDS only."""
import os, socket, sys

def listen_fds():
    if os.environ.get("LISTEN_PID") != str(os.getpid()):
        return []
    n = int(os.environ.get("LISTEN_FDS", "0"))
    # sd_listen_fds starts at FD 3
    return list(range(3, 3 + n))

fds = listen_fds()
if not fds:
    sys.stderr.write("no LISTEN_FDS; start via lyra-listen.socket\n")
    sys.exit(1)

names = os.environ.get("LISTEN_FDNAMES", "").split(":")
sys.stderr.write(f"got {len(fds)} fd(s) names={names!r}\n")

listeners = []
for fd in fds:
    s = socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM)
    # fromfd() dup's; close the passed fd number we no longer need
    os.close(fd)
    s.setblocking(True)
    listeners.append(s)

# Single-listener demo
lsock = listeners[0]
sys.stderr.write("listening via systemd-passed FD\n")
while True:
    conn, addr = lsock.accept()
    with conn:
        conn.sendall(b"lyra-listen: " + repr(addr).encode() + b"\n")
        while True:
            data = conn.recv(4096)
            if not data:
                break
            conn.sendall(data)
EOF
sudo chmod 755 /usr/local/bin/lyra-listen-demo

sudo tee /etc/systemd/system/lyra-listen.service >/dev/null <<'EOF'
[Unit]
Description=Lyra Accept=no LISTEN_FDS demo
Requires=lyra-listen.socket
After=lyra-listen.socket

[Service]
ExecStart=/usr/local/bin/lyra-listen-demo
# Type=simple is fine: the socket already holds the listener
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF

sudo tee /etc/systemd/system/lyra-listen.socket >/dev/null <<'EOF'
[Unit]
Description=Lyra LISTEN_FDS demo socket

[Socket]
ListenStream=127.0.0.1:9100
Accept=no
FileDescriptorName=lyra-demo
# FreeBind=yes  # useful when binding a specific IP before the iface is up
# TriggerLimitIntervalSec=2s TriggerLimitBurst=20  (Accept=no defaults)

[Install]
WantedBy=sockets.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now lyra-listen.socket
# Do NOT start the service yet — prove on-demand activation:
systemctl is-active lyra-listen.service || true   # inactive
printf 'ping\n' | nc -q 1 127.0.0.1 9100
systemctl is-active lyra-listen.service           # active
journalctl -u lyra-listen.service -n 10 --no-pager
Enter fullscreen mode Exit fullscreen mode

Notes that matter in real daemons:

  • Prefer sd_listen_fds() / sd_listen_fds_with_names() from libsystemd over hand-parsing env when you can; they set FD_CLOEXEC and validate pid/pidfd.
  • With multiple ListenStream= lines, all FDs are passed, ordered as configured in that unit.
  • Multiple socket units can point at one service via Service=; order across units is undefined — use FileDescriptorName= and sd_listen_fds_with_names().

Lab 3 — AF_UNIX with mode and ownership

UNIX sockets are how most local desktop/system buses do on-demand activation.

sudo tee /etc/systemd/system/lyra-unix.socket >/dev/null <<'EOF'
[Unit]
Description=Lyra AF_UNIX demo socket

[Socket]
ListenStream=/run/lyra/demo.sock
SocketMode=0660
DirectoryMode=0755
# SocketUser=lyra SocketGroup=lyra   # if the user/group exist
Accept=yes
RemoveOnStop=yes

[Install]
WantedBy=sockets.target
EOF

sudo tee /etc/systemd/system/lyra-unix@.service >/dev/null <<'EOF'
[Unit]
Description=Lyra AF_UNIX connection
CollectMode=inactive-or-failed

[Service]
Type=oneshot
StandardInput=socket
StandardOutput=socket
ExecStart=/usr/bin/bash -c 'printf "unix peer=%s\\n" "${REMOTE_ADDR:-unnamed}"; cat'
PrivateTmp=yes
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now lyra-unix.socket
printf 'hi-unix\n' | nc -U -q 1 /run/lyra/demo.sock
ls -l /run/lyra/demo.sock
Enter fullscreen mode Exit fullscreen mode

Abstract-namespace sockets use @name instead of a filesystem path (ListenStream=@lyra-demo). No inode on disk; good for private IPC, awkward for ad-hoc nc debugging.

Listen family cheat sheet

Directive Socket type Typical address forms
ListenStream= SOCK_STREAM (TCP or UNIX stream) 9090, 127.0.0.1:9090, [::1]:9090, /run/app.sock, @abstract, vsock:CID:port
ListenDatagram= SOCK_DGRAM (UDP or UNIX dgram) same address grammar
ListenSequentialPacket= SOCK_SEQPACKET AF_UNIX only
ListenFIFO= FIFO path absolute path
ListenNetlink= Netlink e.g. kobject-uevent
ListenMessageQueue= POSIX mq name beginning with /
SocketProtocol= udplite / sctp / mptcp pairs with Listen*

Useful knobs when binding network listeners:

  • FreeBind=yes — bind a specific IP before the address exists on an interface (IP_FREEBIND). Recommended whenever you pin a non-wildcard address.
  • BindToDevice=eth0 — SO_BINDTODEVICE; adds device unit deps.
  • BindIPv6Only= — default / both / ipv6-only.
  • Backlog= — listen(2) backlog; silently capped by net.core.somaxconn (often 4096).
  • ReusePort=, NoDelay=, KeepAlive=, DeferAcceptSec= — standard TCP tunables exposed as unit options.
  • PassCredentials= / PassSecurity= — AF_UNIX ancillary creds (journald uses these).

Rate limits: trigger vs poll

Same family of knobs as .path units, with socket-specific defaults (systemd.socket(5)):

Setting Default On limit hit
TriggerLimitIntervalSec= 2s —
TriggerLimitBurst= 200 if Accept=yes, else 20 Socket unit fails; not connectible until restart
PollLimitIntervalSec= 2s (255+) —
PollLimitBurst= 150 if Accept=yes, else 15 Temporary slowdown of polling (DoS soft brake)

PollLimit* is the preferred flood brake. TriggerLimit* is the hard tripwire. Service StartLimitIntervalSec= / StartLimitBurst= still apply independently.

MaxConnections= (default 64) and MaxConnectionsPerSource= (default 0 = off) only apply to Accept=yes.

Parallel start and crash resilience

Lennart Poettering’s classic socket-activation write-up still holds:

  • Listening sockets are created early; dependent daemons can start in parallel and talk immediately (writes queue in the socket buffer until the server catches up).
  • If the service crashes but the socket unit stays up, clients do not get “connection refused” during the restart window — the backlog holds.
  • Upgrades can restart the service process while keeping the same listening FDs.

Stock systemd-journald.socket is the textbook multi-listener unit: datagram + stream, credential passing, dedicated Service= name.

Bridging apps that cannot speak LISTEN_FDS

systemd-socket-proxyd(8) accepts the systemd-passed listener (Accept=no), then bidirectional-proxies each client to a backend TCP or UNIX socket. That is how you give socket activation to a daemon that only knows listen on its own path.

Sketch from the man page (nginx behind a host port):

# proxy-to-app.socket
[Socket]
ListenStream=80

[Install]
WantedBy=sockets.target
Enter fullscreen mode Exit fullscreen mode
# proxy-to-app.service
[Unit]
Requires=app.service
After=app.service
Requires=proxy-to-app.socket
After=proxy-to-app.socket

[Service]
Type=notify
ExecStart=/usr/lib/systemd/systemd-socket-proxyd /run/app.sock
PrivateTmp=yes
Enter fullscreen mode Exit fullscreen mode

Useful options:

  • --connections-max= (default 256)
  • --exit-idle-time= so the proxy (and a StopWhenUnneeded= backend) can idle down
  • --proxy-protocol=v1 (261+) when the backend should see the real client IP
  • JoinsNamespaceOf= when the backend uses PrivateNetwork= / PrivateTmp=

Caveat: side channels (SCM_RIGHTS, SO_PEERCRED, …) are not forwarded.

Service wiring checklist

[Unit]
Requires=myapp.socket
After=myapp.socket
# optional: Sockets=myapp.socket   # pulls socket via Wants+After from the service side

[Service]
# Accept=no native daemon:
ExecStart=/usr/local/bin/myapp
# OR inetd-style:
# StandardInput=socket
# StandardOutput=socket

# Accept=yes template extras:
# CollectMode=inactive-or-failed
Enter fullscreen mode Exit fullscreen mode

Install section belongs on the socket for on-demand:

[Install]
WantedBy=sockets.target
Enter fullscreen mode Exit fullscreen mode
sudo systemctl enable --now myapp.socket
# multi-user pull-in of sockets.target starts your listeners at boot
Enter fullscreen mode Exit fullscreen mode

Verification and debugging

systemctl status myapp.socket
systemctl show myapp.socket -p Listen -p Accept -p NAccepted -p NConnections
ss -ltnp | grep 9100          # or ss -xlp for UNIX
journalctl -u myapp.socket -u myapp.service -b --no-pager

# See what stock units look like when something already works:
systemctl cat dbus.socket systemd-hostnamed.socket
Enter fullscreen mode Exit fullscreen mode

Common failures:

Symptom Likely cause
Service starts but cannot bind port App still binds itself and ignores LISTEN_FDS — double bind
no LISTEN_FDS in app Started without socket; or $LISTEN_PID mismatch after an unexpected fork
Template name errors Accept=yes but service is not name@.service
Socket dead after flood Hit TriggerLimit*; check systemctl status and reset with systemctl restart myapp.socket
Stale AF_UNIX node Left behind after crash with RemoveOnStop=no (default); stop unit or remove carefully
Works on IPv6 only / unexpected dual-stack Review BindIPv6Only= and whether you passed a bare port vs 127.0.0.1:

Cleanup the labs

sudo systemctl disable --now \
  lyra-echo.socket lyra-listen.socket lyra-unix.socket 2>/dev/null || true
sudo systemctl stop lyra-listen.service 2>/dev/null || true
sudo rm -f \
  /etc/systemd/system/lyra-echo.socket \
  /etc/systemd/system/lyra-echo@.service \
  /etc/systemd/system/lyra-listen.socket \
  /etc/systemd/system/lyra-listen.service \
  /etc/systemd/system/lyra-unix.socket \
  /etc/systemd/system/lyra-unix@.service \
  /usr/local/bin/lyra-listen-demo
sudo systemctl daemon-reload
Enter fullscreen mode Exit fullscreen mode

Boundaries (what this post is not)

  • Not .path drop-directory activation — that is filesystem inotify, covered separately.
  • Not .timer schedules.
  • Not a deep dive on busctl / Varlink method calls (those are APIs over sockets).
  • Not full SSH hardening or nginx design — only the activation edge.
  • Not kqueue/launchd on macOS; same idea, different unit language.

When to use which activation unit

Goal Unit
First TCP/UNIX connection starts app .socket
File appears / directory not empty .path
Calendar or monotonic schedule .timer
Block device / pluggable hardware .device + bindings
Manual or dependency boot start only plain .service

Takeaways

  1. Enable the .socket, keep the service on-demand (or share FDs for parallel boot).
  2. Choose Accept=no for real daemons and Accept=yes for inetd-style one-shot connections.
  3. Teach apps sd_listen_fds / $LISTEN_FDS, or put StandardInput=socket in front of simple helpers.
  4. Use FreeBind=, FileDescriptorName=, and PollLimit* deliberately on network edges.
  5. For stubborn legacy listeners, systemd-socket-proxyd is the supported bridge — not a home-grown socat unit without notify/cgroup integration.

Once the listening FD lives in systemd, “is the port up?” stops meaning “is the whole app up?” — and that is a much calmer way to run rarely used services on a Linux host.

References

Top comments (0)