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 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-proxydor fix the app).
It does replace most “always-on listener for rarely used ports” and pairs naturally with on-demand tools.
Mental model
- You install
foo.socketandfoo.service(same basename by default). - You enable and start the socket unit, not necessarily the service.
- systemd binds and listens before (or without) the service process.
- On incoming traffic, systemd starts the service and passes listening FDs (or one accepted connection).
- If the service exits and the socket unit stays up, the port keeps listening. The next connection can start the service again.
- There is no implicit
WantedBy=from socket → service. The service can still be started alone; if you need “service requires its socket”, add an explicitRequires=/After=on the service (or useSockets=).
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 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 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, setCollectMode=inactive-or-failedon 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_PIDmust match the receiving process (since 259,$LISTEN_PIDFDIDis also checked when set). - Optional
$LISTEN_FDNAMEScolon-separated names fromFileDescriptorName=or defaults. - systemd sets
FD_CLOEXECon the passed FDs. - Do not
shutdown(2)listening sockets you got withAccept=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 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 What just happened:
-
lyra-echo.socketbound127.0.0.1:9099and sat inlistening. -
ncconnected; systemdaccept()’d and spawnedlyra-echo@<conn>.service. - The service saw the client on stdin/stdout, echoed, exited.
- 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' 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 Notes that matter in real daemons:
- Prefer
sd_listen_fds()/sd_listen_fds_with_names()fromlibsystemdover hand-parsing env when you can; they setFD_CLOEXECand 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 — useFileDescriptorName=andsd_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 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 bynet.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 # 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 Useful options:
-
--connections-max=(default 256) -
--exit-idle-time=so the proxy (and aStopWhenUnneeded=backend) can idle down -
--proxy-protocol=v1(261+) when the backend should see the real client IP -
JoinsNamespaceOf=when the backend usesPrivateNetwork=/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 Install section belongs on the socket for on-demand:
[Install] WantedBy=sockets.target sudo systemctl enable --now myapp.socket # multi-user pull-in of sockets.target starts your listeners at boot 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 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 Boundaries (what this post is not)
- Not
.pathdrop-directory activation — that is filesystem inotify, covered separately. - Not
.timerschedules. - 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
- Enable the
.socket, keep the service on-demand (or share FDs for parallel boot). - Choose
Accept=nofor real daemons andAccept=yesfor inetd-style one-shot connections. - Teach apps
sd_listen_fds/$LISTEN_FDS, or putStandardInput=socketin front of simple helpers. - Use
FreeBind=,FileDescriptorName=, andPollLimit*deliberately on network edges. - For stubborn legacy listeners,
systemd-socket-proxydis the supported bridge — not a home-grownsocatunit 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
-
systemd.socket(5)— socket unit options, Accept=, Listen*, limits (man systemd.socket) -
systemd.service(5)— service templates,Sockets=, activation ordering -
sd_listen_fds(3),sd_listen_fds_with_names(3)— FD passing protocol ($LISTEN_FDS, FD 3+) -
systemd-socket-proxyd(8)— proxy helper for non-native socket activation -
systemd.unit(5),systemd.exec(5)— dependencies, sandboxing,StandardInput= - Stock units:
ssh.socket,dbus.socket,systemd-hostnamed.socket,systemd-journald.socket - Lennart Poettering, Socket Activation (systemd for Developers): https://0pointer.net/blog/projects/socket-activation.html
- Debian manpages mirror: https://manpages.debian.org/testing/systemd/systemd.socket.5.en.html
- Arch man pages: https://man.archlinux.org/man/systemd.socket.5