Service Lifecycle
Overview
Every service registered in nixda.zones that exposes at least one port receives a pair of
systemd units generated by proxy-service.nix: a socket unit and a proxy service unit. These two
units mediate all traffic between Caddy and the backend workload, regardless of lifecycle mode.
For runtime-specific configuration details, see the Native runtime and OCI runtime pages. For zone and service definition syntax, see Zone Configuration.
Nixda Stack supports two lifecycle modes:
| Mode | idleTimeout value | autoStart | Behavior |
|---|---|---|---|
| Always-on | null | true | Backend starts at boot; runs continuously |
| On-demand | positive integer (default: 43200) | false | Backend starts on first request; stops after idle |
The proxy layer is identical in both modes — socket activation is always in effect. The difference
is whether the idle timer (--exit-idle-time) is passed to systemd-socket-proxyd, and whether
PropagatesStopTo is set to cascade proxy shutdown to the backend.
Socket Activation Flow (On-Demand)
When idleTimeout is set, the backend container does not start at boot (autoStart = false). The
following sequence occurs on the first inbound request:
-
A client request arrives at Caddy. Caddy resolves the service’s
.landomain to the host’s reverse proxy IP and routes it to127.0.0.1:{activationPort}. -
The systemd socket unit
proxy-{name}.socketis listening on that port. The kernel passes the accept-queued connection to systemd rather than any process. -
systemd socket activation triggers
proxy-{name}.service. Because the socket unit already holds the file descriptor, the proxy service starts without losing the client connection. -
systemd-socket-proxydinherits the socket from systemd and immediately opens a TCP connection to the backend at{serviceIp}:{backendPort}. -
The backend unit is a hard dependency via
BindsTo=. If it is not already running, systemd starts it before the proxy service completes startup. For OCI services this ispodman-{name}.service; for native NixOS containers it iscontainer@{name}.service. -
Once the backend is ready and accepting connections,
systemd-socket-proxydbridges the TCP stream. The client request is forwarded and the response returns through the same path. -
After
idleTimeoutseconds of no active connections,systemd-socket-proxydexits due to--exit-idle-time={idleTimeout}. -
Because
PropagatesStopTo={backendUnit}is set on the proxy service, systemd stops the backend container as a consequence of the proxy exiting. The socket unit remains active, ready to trigger the next cold start.
The activation port is kept by the socket unit across start/stop cycles of the proxy and backend.
The client observes a connection that is held open during cold start — Caddy’s reverse proxy keeps
the upstream connection alive while systemd-socket-proxyd waits for the backend to become
ready.
Always-On Services
When idleTimeout = null, the module omits both the idle timer and the stop propagation:
--exit-idle-timeis not passed tosystemd-socket-proxyd, so the proxy never self-terminates due to inactivity.PropagatesStopTois absent from the proxy unit, so stopping the proxy does not cascade to the backend.autoStartremains at its default (true), so the backend container or service starts unconditionally with the host.
The socket unit is still present. All traffic still flows through systemd-socket-proxyd. Socket
activation is the architectural constant — the difference from on-demand mode is solely the
absence of an idle timer and stop propagation.
Always-on is the correct choice when:
- The service maintains persistent state that must survive between requests (Syncthing sync mesh, forgejo-runner job queues).
- Cold-start latency is unacceptable for the workload.
- The service has no meaningful concept of “idle” (cockpit health checks, terminal sessions).
Catalog blueprints that set idleTimeout = null: syncthing, forgejo-runner, terminal,
cockpit.
systemd Unit Relationships
Each proxied service generates exactly two systemd units in addition to the backend unit created by the runtime module. The full dependency graph per service is:
proxy-{name}.socket
│
│ (triggers on connection)
▼
proxy-{name}.service ←── BindsTo ──► {backendUnit}
│ │
│ PropagatesStopTo (on-demand only) │
└───────────────────────────────────────────────►
proxy-{name}.socket
The listener unit. Binds to 127.0.0.1:{activationPort} and is part of sockets.target,
so it is active as long as the host is running. It does not depend on the backend.
restartTriggers watches four values derived from the service definition:
| Trigger value | Reason |
|---|---|
activationPort | Listening address changed |
backendIp | Zone move or subnet change |
backendPort | Service port change |
service.idleTimeout | Timeout argument to systemd-socket-proxyd changed |
Any change to these values during a nixos-rebuild switch causes the socket to restart, which
also restarts the proxy service and re-establishes the backend connection.
Source: modules/service/proxy-service.nix:99-120
proxy-{name}.service
The forwarding unit. Runs systemd-socket-proxyd {--exit-idle-time=N} {ip}:{port}.
This unit is not started directly — it is instantiated by socket activation.
Key unit directives:
| Directive | Value | Condition |
|---|---|---|
BindsTo | {backendUnit} | backend unit exists (not system runtime) |
PropagatesStopTo | {backendUnit} | idleTimeout != null AND backend unit exists |
After | {backendUnit}, network.target | backend unit exists |
Requires | {backendUnit}, network.target | backend unit exists |
BindsTo creates a hard lifecycle coupling in both directions within the proxy service’s
lifetime: if the backend stops unexpectedly, the proxy also stops. The converse — proxy stop
cascading to backend — only applies when PropagatesStopTo is present, which requires
idleTimeout != null.
Source: modules/service/proxy-service.nix:123-151
Backend unit (runtime-dependent)
The backend unit is not generated by proxy-service.nix. It is created by the respective
runtime module (native-service.nix, oci-service.nix, vm-service.nix). The proxy module
derives the unit name from the runtime type at evaluation time:
getBackendUnit = name: svc:
if svc.runtime.native != null then "container@${name}.service"
else if svc.runtime.oci != null then "podman-${name}.service"
else if svc.runtime.vm != null then "microvm@${name}.service"
else null;
Source: modules/service/proxy-service.nix:40-49
When the runtime is system, getBackendUnit returns null. The proxy unit is generated
without BindsTo or PropagatesStopTo, and ExecStart uses 127.0.0.1 as the backend address
instead of a zone-derived IP. The system service itself is responsible for its own lifecycle.
Activation Port Formula
Every proxied service requires a unique localhost port. The formula is:
activationPort = zone.startPort + service.id
Where zone.startPort defaults to:
zone.startPort = nixda.stack.proxy.startPort + zone.vlan * 100
nixda.stack.proxy.startPort defaults to 40000.
Example: proxy.startPort = 40000, zone.vlan = 20, service.id = 5
zone.startPort = 40000 + (20 × 100) = 42000
activationPort = 42000 + 5 = 42005
This port is bound to 127.0.0.1 only, is never exposed on any network interface, and is
invisible outside the host. Caddy routes to it; nothing else accesses it directly.
The formula guarantees uniqueness within a zone provided service IDs are unique within that zone
— an assertion enforced by zones/default.nix. Ports across zones cannot collide as long as
proxy.startPort + (vlan × 100) intervals do not overlap, which holds for the default
configuration with the supported VLAN range.
Source: modules/zones/default.nix:27-31, modules/service/proxy-service.nix:61,
modules/stack/proxy.nix:17-20
Backend IP Resolution
The address systemd-socket-proxyd connects to depends on the runtime:
| Runtime | Backend address |
|---|---|
| Native | {zone.subnet}.{service.id} (zone bridge IP) |
| OCI | {zone.subnet}.{service.id} (Podman network IP) |
| VM | {zone.subnet}.{service.id} (MicroVM bridge IP) |
| System | 127.0.0.1 (loopback; service runs on host) |
The formula for non-system runtimes is implemented in stack/lib.nix:
getIp = zone: id: "${catalog.${zone}.subnet}.${toString id}";
For the native and OCI runtimes, the service IP is the static IP assigned to the container on the zone bridge. The proxy connects directly to that address; no port publishing or NAT is involved.
Source: modules/stack/lib.nix:22, modules/service/proxy-service.nix:105,128
On-Demand Enforcement
When idleTimeout != null, the module forces autoStart = false on the backend container using
lib.mkForce. This prevents the container manager from starting the backend at boot, ensuring
that cold starts only occur through socket activation:
# OCI containers
virtualisation.oci-containers.containers =
lib.mapAttrs (_: _: { autoStart = lib.mkForce false; })
(lib.filterAttrs (_: { service, ... }: service.runtime.oci != null && service.idleTimeout != null)
proxyServices);
# Native NixOS containers
containers =
lib.mapAttrs (_: _: { autoStart = lib.mkForce false; })
(lib.filterAttrs (_: { service, ... }: service.runtime.native != null && service.idleTimeout != null)
proxyServices);
The lib.mkForce ensures this override wins over any autoStart = true set by the runtime
module or a consumer override. Without it, a consumer could inadvertently enable boot-time
startup for an on-demand service by setting autoStart = true in a blueprint override.
Source: modules/service/proxy-service.nix:154-182