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:

ModeidleTimeout valueautoStartBehavior
Always-onnulltrueBackend starts at boot; runs continuously
On-demandpositive integer (default: 43200)falseBackend 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:

  1. A client request arrives at Caddy. Caddy resolves the service’s .lan domain to the host’s reverse proxy IP and routes it to 127.0.0.1:{activationPort}.

  2. The systemd socket unit proxy-{name}.socket is listening on that port. The kernel passes the accept-queued connection to systemd rather than any process.

  3. 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.

  4. systemd-socket-proxyd inherits the socket from systemd and immediately opens a TCP connection to the backend at {serviceIp}:{backendPort}.

  5. 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 is podman-{name}.service; for native NixOS containers it is container@{name}.service.

  6. Once the backend is ready and accepting connections, systemd-socket-proxyd bridges the TCP stream. The client request is forwarded and the response returns through the same path.

  7. After idleTimeout seconds of no active connections, systemd-socket-proxyd exits due to --exit-idle-time={idleTimeout}.

  8. 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-time is not passed to systemd-socket-proxyd, so the proxy never self-terminates due to inactivity.
  • PropagatesStopTo is absent from the proxy unit, so stopping the proxy does not cascade to the backend.
  • autoStart remains 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 valueReason
activationPortListening address changed
backendIpZone move or subnet change
backendPortService port change
service.idleTimeoutTimeout 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:

DirectiveValueCondition
BindsTo{backendUnit}backend unit exists (not system runtime)
PropagatesStopTo{backendUnit}idleTimeout != null AND backend unit exists
After{backendUnit}, network.targetbackend unit exists
Requires{backendUnit}, network.targetbackend 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:

RuntimeBackend 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)
System127.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