Skip to content

Gate systemd consumers on actual service readiness

Modules

After= orders unit startup; it does not prove that a dependency is ready. systemd considers a Type=simple service started as soon as its process forks, often before migrations, warmup, socket binding, or an HTTP health check finish.

This module turns a command, TCP connection, or HTTP request into a oneshot <name>-ready.service that dependent units can safely require.

Use

{
  imports = [ inputs.recipes.nixosModules.service-readiness-gate ];

  modules.services.readiness-gates = {
    garage = {
      http = "http://127.0.0.1:3903/health";
      after = [ "garage.service" ];
      requiredBy = [ "garage-init.service" ];
      timeoutSeconds = 120;
    };
  };
}

Each gate must set exactly one probe:

  • command: a shell command that exits zero when ready;
  • tcp: a { host, port } connection check;
  • http: a URL that must return a successful HTTP status.

Setting zero or several probe types fails evaluation with the gate's name.

Contract

For a gate named foo, the module:

  • creates foo-ready.service as a Type=oneshot unit;
  • orders and requires its own dependencies through after and requires;
  • adds Requires=foo-ready.service and After=foo-ready.service to every unit named by requiredBy;
  • keeps the successful gate active with RemainAfterExit=true.

requiredBy accepts names with or without further unit configuration; the module removes a .service suffix when extending systemd.services.

Several gates may guard the same consumer. Their dependency lists accumulate: the implementation uses lib.mkMerge, not recursive-update-by-accident.

Failure behavior

When timeoutSeconds expires, the gate fails and its required consumers remain stopped. This is intentional: one failed readiness unit is more diagnosable than a consumer restarting forever against a dependency that never became usable.

Probe attempts repeat every intervalSeconds; the default is one second.

Traps this avoids

After= is not a health check

Ordering against the raw service only waits for systemd's start transition. It does not wait for the daemon to accept connections.

Exactly one probe keeps failures legible

Implicit precedence between command, tcp, and http would make a typo look like a successful configuration. Ambiguous gates fail during evaluation.

TCP probing needs Bash

The TCP probe uses Bash's /dev/tcp support under a short timeout. The generated waiter carries Bash, curl, and coreutils explicitly and accepts extraPackages for custom command probes.

Testing

test.nix is a NixOS VM test. Run it directly:

```console $ nix-build test.nix --arg pkgs 'import { system = "x86_64-linux"; }'

Source

modules/service-readiness-gate/default.nix

```nix { config, lib, pkgs, ... }: let cfg = config.modules.services.readiness-gates;

inherit (lib) mkOption mkIf types mapAttrs' nameValuePair ;

gateUnit = name: "${name}-ready.service";

probeCommand = name: gate: let set = lib.filter (p: p != null) [ (if gate.command != null then "command" else null) (if gate.tcp != null then "tcp" else null) (if gate.http != null then "http" else null) ]; in if lib.length set != 1 then throw "readiness gate '${name}': set exactly one of command, tcp or http (got ${toString (lib.length set)})" else if gate.command != null then gate.command else if gate.tcp != null then "timeout 2 bash -c 'exec 3<>/dev/tcp/${gate.tcp.host}/${toString gate.tcp.port}'" else "curl -sf -o /dev/null --max-time 5 ${lib.escapeShellArg gate.http}";

waitScript = name: gate: pkgs.writeShellApplication { name = "wait-for-${name}"; runtimeInputs = [ pkgs.coreutils pkgs.bash pkgs.curl ] ++ gate.extraPackages; text = '' set -euo pipefail

    deadline=$(( SECONDS + ${toString gate.timeoutSeconds} ))
    until ${probeCommand name gate}; do
      if [ "$SECONDS" -ge "$deadline" ]; then
        echo "readiness gate '${name}': not ready after ${toString gate.timeoutSeconds}s" >&2
        exit 1
      fi
      sleep ${toString gate.intervalSeconds}
    done
    echo "readiness gate '${name}': ready"
  '';
};

gateOptions = types.submodule ( { name, ... }: { options = { command = mkOption { type = types.nullOr types.str; default = null; example = "pg_isready -q"; description = "Shell command that exits 0 once the dependency is ready."; };

    tcp = mkOption {
      type = types.nullOr (
        types.submodule {
          options = {
            host = mkOption {
              type = types.str;
              default = "127.0.0.1";
            };
            port = mkOption { type = types.port; };
          };
        }
      );
      default = null;
      description = "Wait until this TCP port accepts a connection.";
    };

    http = mkOption {
      type = types.nullOr types.str;
      default = null;
      example = "http://127.0.0.1:3903/health";
      description = "Wait until this URL answers with a success status.";
    };

    timeoutSeconds = mkOption {
      type = types.ints.positive;
      default = 120;
      description = ''
        Give up after this long. The gate then fails, which is the point:
        a dependent held back by a failed gate is far easier to diagnose
        than one restarting forever against something that never arrived.
      '';
    };

    intervalSeconds = mkOption {
      type = types.ints.positive;
      default = 1;
      description = "Seconds between probe attempts.";
    };

    after = mkOption {
      type = types.listOf types.str;
      default = [ ];
      example = [ "postgresql.service" ];
      description = "Units the gate itself is ordered after.";
    };

    requires = mkOption {
      type = types.listOf types.str;
      default = [ ];
      description = "Units the gate pulls in and depends on.";
    };

    requiredBy = mkOption {
      type = types.listOf types.str;
      default = [ ];
      example = [ "garage-init.service" ];
      description = ''
        Units that must not start until this gate has passed. Each gets
        `Requires=` and `After=` the gate, so a failed probe blocks them
        rather than letting them start against an unready dependency.
      '';
    };

    extraPackages = mkOption {
      type = types.listOf types.package;
      default = [ ];
      description = "Extra packages on the probe's PATH.";
    };

    wantedBy = mkOption {
      type = types.listOf types.str;
      default = [ "multi-user.target" ];
      description = "Targets that pull the gate in.";
    };
  };
}

); in { options.modules.services.readiness-gates = mkOption { type = types.attrsOf gateOptions; default = { }; description = '' Readiness gates for units whose dependencies are ready some time after systemd considers them started.

  `After=` orders starts, not readiness: a Type=simple service counts as
  started the moment it forks, which is typically well before it accepts
  connections. Anything ordered only with `After=` therefore races it, and
  the usual symptom is a dependent that restarts forever, or worse, a setup
  step that silently does nothing and still reports success.

  A gate is a oneshot that polls until the dependency actually answers, so
  dependents can take a hard `Requires=` on something meaningful.
'';
example = lib.literalExpression ''
  {
    garage = {
      http = "http://127.0.0.1:3903/health";
      after = [ "garage.service" ];
      requiredBy = [ "garage-init.service" ];
    };
  }
'';

};

config = mkIf (cfg != { }) { # mkMerge, not //: several gates may guard the same consumer, and their # Requires=/After= lists have to accumulate rather than overwrite. systemd.services = lib.mkMerge ( [ (mapAttrs' ( name: gate: nameValuePair "${name}-ready" { description = "Wait until ${name} is ready"; inherit (gate) after requires wantedBy; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = lib.getExe (waitScript name gate); }; } ) cfg) ] ++ lib.flatten ( lib.mapAttrsToList ( name: gate: map (consumer: { ${lib.removeSuffix ".service" consumer} = { requires = [ (gateUnit name) ]; after = [ (gateUnit name) ]; }; }) gate.requiredBy ) cfg ) ); }; } ```