systemd Socket Activation: Designing a Correct Listener Handoff
Build and diagnose systemd socket-activated services by tracing listener ownership, descriptor handoff, Accept modes, restart behavior, and readiness boundaries.
Socket activation moves the lifetime of a listening socket out of an application process and into systemd. The manager can bind a configured address before the service starts, queue a connection, and start the service when traffic arrives. This is useful for infrequently used daemons, precise listener ownership, and service isolation, but it is not a generic switch that makes any server socket-activatable. The application must accept the inherited descriptor contract, and the unit must match the application’s concurrency model.
The key operational distinction is between the socket unit and the service unit. The .socket unit owns the listening endpoint and can remain active while the service is stopped. The .service unit owns the work performed after activation. A connection arriving at the managed listener may cause systemd to queue work and start the matching service. With the common Accept=no model, the service receives the listening socket and accepts connections itself. With Accept=yes, systemd accepts each connection and starts an instance service for the connected stream socket. These are different programming contracts, not interchangeable configuration styles.
Choose the activation model before writing units
Use Accept=no when the daemon already has an event loop or worker pool that accepts from one or more listening descriptors. It is normally the right model for a conventional network server designed to call accept() itself. The service needs to discover the inherited descriptor, often through sd_listen_fds() or a compatible library, and must avoid binding a second listener to the same address.
Use Accept=yes only when each accepted connection can be handled by a separate service instance and the application is designed to consume a connected stream descriptor rather than a listener. The socket unit then pairs with a template service such as [email protected]. Per-connection processes can be appropriate for small request handlers, but they add process-start overhead and may not fit protocols that require long-lived shared state.
Do not infer support from a daemon being able to bind a TCP port. A server that ignores systemd’s passed file descriptors may start successfully but listen nowhere, fail with address already in use, or create an unintended second endpoint. Confirm the program’s documentation and test a staging unit before switching a production listener.
A minimal Accept=no unit pair
This example binds only to loopback on a high test port. The executable path and activation option are illustrative: replace them with the actual server’s documented socket-activation interface. The application must use the descriptor passed by systemd and not call bind() for this same address.
# /etc/systemd/system/demo-api.socket
[Unit]
Description=Loopback listener for the demo API
[Socket]
ListenStream=127.0.0.1:18088
Accept=no
Service=demo-api.service
[Install]
WantedBy=sockets.target
# /etc/systemd/system/demo-api.service
[Unit]
Description=Socket-activated demo API
[Service]
Type=exec
ExecStart=/usr/local/bin/demo-api --systemd-socket-activation
Restart=on-failure
Type=exec reports startup after the executable has been invoked, not after the application has completed an application-specific readiness protocol. If the program supports Type=notify, configure that separately and send READY=1 only after it is prepared to serve. Socket activation and readiness notification solve different lifecycle questions: the former hands off a listener, while the latter tells the manager when startup work is complete.
Validate the syntax before enabling the pair. A staged copy under /run/systemd/system can be useful for experiments, but remember that runtime units disappear at reboot. For a normal local test, install both files, reload the manager, and verify that the socket is active before sending traffic:
sudo systemd-analyze verify /etc/systemd/system/demo-api.socket /etc/systemd/system/demo-api.service
sudo systemctl daemon-reload
sudo systemctl enable --now demo-api.socket
systemctl status demo-api.socket
systemctl is-active demo-api.service || true
ss -ltnp 'sport = :18088'
The service can be inactive while the socket is listening; that is the intended idle state. Send a request with the protocol’s real client, then inspect both units and the journal:
curl --fail --verbose http://127.0.0.1:18088/health
systemctl status demo-api.socket demo-api.service
journalctl -u demo-api.socket -u demo-api.service --since '-5 minutes'
The curl command is only valid if the program implements HTTP and the chosen health path. For a different protocol, use its client rather than treating a successful TCP handshake as proof of application health.
Understand the descriptor handoff
For a service using the systemd file-descriptor protocol, the manager supplies environment variables that identify the process and number of descriptors, including LISTEN_PID and LISTEN_FDS; named descriptors can be conveyed using LISTEN_FDNAMES. The application should use the documented helper library or implement the protocol exactly. A correct implementation validates that the environment refers to its own process, marks or duplicates descriptors as required by its runtime, and accepts from the inherited listener.
The contract is process-scoped. A wrapper shell that launches the actual daemon can complicate descriptor ownership and LISTEN_PID validation. Prefer an ExecStart that directly executes the server, or use a wrapper that deliberately preserves and hands off the descriptor contract. Do not blindly unset the environment variables or close all descriptors at startup before the server library has consumed them.
Multiple listeners can be configured in one socket unit, including different protocols or paths. The application must map inherited descriptors to the intended endpoints; relying on an assumed descriptor order is fragile if unit configuration changes. Use named descriptors when supported, and log the address and identity of each listener at startup without leaking sensitive information.
Lifecycle, restarts, and traffic behavior
Stopping the service does not necessarily stop its socket unit. If the socket remains active, a later connection can activate the service again. This distinction explains why a process may appear to return after an operator runs systemctl stop demo-api.service: stopping only the worker leaves the activation source enabled. To make the endpoint unavailable, stop the .socket unit as well. To disable activation at boot, disable the socket unit rather than only the service.
When a service exits while the socket remains open, systemd’s activation path may start it again on subsequent traffic. Restart= handles service failures according to its configured policy; it does not replace understanding which unit is still listening. During incident response, inspect systemctl list-sockets, the socket unit’s status, the service’s restart counter, and the actual process tree before concluding that Restart= caused every start.
For Accept=no, queued connections wait for the service to become available according to the kernel listener backlog and application behavior. A backlog is not unlimited durable storage. A long initialization, a saturated accept loop, or a stopped service can cause clients to time out or be refused. For Accept=yes, per-connection instances have different queue and resource behavior; consider limits, rate control, and cleanup for idle or abusive clients. Neither mode promises exactly-once request execution: clients may retry, disconnect, or submit a request whose outcome is ambiguous after a network failure.
Binding to a public address exposes a service as soon as the socket unit starts, even if the application service is not running yet. Apply firewall policy and authentication at the right layer, and verify IPv4/IPv6 behavior explicitly. A ListenStream= address of 127.0.0.1 is not equivalent to 0.0.0.0, ::, or a wildcard. When multiple addresses are configured, validate each one with ss and a client from the intended network.
Failure diagnosis and acceptance criteria
If the service starts but does not answer, check whether the executable supports activation, whether the matching service is named correctly, whether LISTEN_FDS is nonzero, and whether the server is trying to bind its own listener. If the socket fails, inspect address conflicts, permissions for Unix sockets, and unit verification output. For a pathname socket, examine directory ownership and cleanup behavior; a stale filesystem node or an unwritable parent directory can prevent binding.
An operational acceptance test should demonstrate all of the following: the socket is active and bound to only the intended addresses; the service is initially idle if that is the design; a valid client request starts it; the application accepts the inherited descriptor without a duplicate bind; logs identify startup and request outcome; stopping the service while leaving the socket active has the expected reactivation behavior; and stopping the socket actually closes the endpoint. Repeat after reboot or deployment using the persistent unit files rather than relying on a manually started process.
Socket activation is a listener-lifecycle contract. Make the application, socket unit, service unit, and operational expectation agree, then verify the descriptor handoff with real traffic. When those pieces are explicit, activation can reduce idle processes and centralize listener ownership without hiding service readiness or failure semantics.
Related:
- Understanding systemd: Units, Targets, and the Modern Linux Init System
- How to Replace a Cron Job with a systemd Timer
Sources: