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
Top comments (0)