OCI Runtime (Podman)

Overview

The OCI runtime runs container images via Podman with rootless networking through Netavark. Each zone gets a dedicated Netavark network bridged to the VLAN interface. Containers receive static IPs within the zone subnet, eliminating the need for port publishing — the reverse proxy reaches containers directly via their static IP.

Defining an OCI Service

Add a service entry under nixda.zones.<zone>.services and set the runtime.oci attribute:

nixda.zones.private.services.gitea = {
  id = 30;
  ports = [ 3000 ];
  runtime.oci = {
    image = "gitea/gitea:latest";
    environment = {
      TZ = "Europe/Berlin";
    };
    volumes = [
      "/data/gitea:/data"
    ];
  };
};

Network Setup

Nixda Stack configures Podman networking automatically based on which zones have OCI services:

  • Podman enabled automatically when any OCI service exists in any zone.
  • Per-zone Netavark network created with the name zone-{zoneName}, bridged to the zone’s VLAN interface.
  • Static IP assigned to each container: --ip={zone.subnet}.{service.id}.
  • No port publishing (-p) is needed — Caddy reaches containers directly via their static IP within the zone network.
  • Network bootstrap handles the temporary IP conflict that arises with the host bridge address during zone network initialization.

Image and Environment

OptionDescription
imageAny OCI image reference, e.g. "nginx:latest" or "ghcr.io/org/app:v1".
environmentKey-value pairs passed as container environment variables.

Images are pulled with --pull=newer at container start, so the local image is refreshed whenever a newer tag exists in the registry.

Volumes

Volumes are declared as a list of "host_path:container_path" strings:

volumes = [
  "/data/gitea:/data"
];

Container storage is ephemeral. Any data that must survive container restarts or image updates must be stored on a host-mounted volume.

Idle Timeout

The idleTimeout option controls how long a container may remain idle before it is stopped:

ValueBehavior
Integer (seconds)Container stops after this many seconds of inactivity. Default: 43200 (12 hours).
nullContainer runs continuously (always-on). autoStart is set to true.

When idleTimeout is set to a non-null value, autoStart is forced to false. The container starts on the first inbound request via socket activation and stops again after the configured idle period.

For the full socket activation flow and systemd unit relationships, see Service Lifecycle. For zone placement and ID allocation, see Zone Configuration.

Example: Using a Blueprint

Blueprints in nixda.catalog provide opinionated defaults. Merge a blueprint with lib.recursiveUpdate and supply only the host-specific values:

nixda.zones.private.services.uptime = lib.recursiveUpdate
  config.nixda.catalog.uptime
  {
    id = 15;
  };

The uptime blueprint provides the native runtime configuration, port 3001, and the default idle timeout. The id is always host-specific and must be supplied by the caller.

Pinning a Blueprint to an Explicit Version

Migrated blueprints (per ADR-0001) additionally expose a constructor under config.nixda.blueprints.<name> so consumers can override the version pins the blueprint ships with. Call mk with a versions attrset to supply explicit tags:

nixda.zones.private.services.jdownloader = lib.recursiveUpdate
  (config.nixda.blueprints.jdownloader.mk {
    versions = { main = "v26.03.1"; };
  })
  { id = 20; };

Calling mk { } (no versions argument) yields the same attrset exposed at config.nixda.catalog.jdownloader. Unrecognized version keys are rejected at evaluation time. The config.nixda.catalog.<name> surface remains available for blueprints whose version pinning is left to the blueprint’s recommended defaults.

Drift Diagnostics

When a migrated blueprint is wired into a zone, the versions module inspects the resulting service and emits diagnostics for four classes of drift:

DiagnosticWhen it firesSeverity
InheritanceConsumer called mk { } and inherited every key from the blueprint’s recommended map.Warning
DivergenceConsumer pinned a key to a value that differs from recommended.<key>.Warning
Silent image overrideDownstream code rewrote runtime.oci.image (or a pod-member image) to a string the blueprint did not compose.Warning
Partial pinConsumer supplied a versions attrset whose key set is a strict subset of recommended.Throw — evaluation fails
Unknown keyConsumer supplied a versions key that the blueprint does not declare.Throw — evaluation fails

The two throw cases are structural — they fire at mk construction time regardless of strict mode, because a partial or unknown-key versions map cannot be completed into a coherent service. The three warning cases are surfaced post-materialization by modules/versions/drift.nix.

Strict Mode

nixda.versions.strict toggles the severity of the three warning-class diagnostics. The default (false) keeps them as config.warnings; setting strict = true escalates each diagnostic to a failing config.assertions entry so nixos-rebuild and nix flake check fail. The diagnostic message text is byte-identical between the two paths — only the severity changes.

{
  nixda.versions.strict = true;

  nixda.zones.private.services.jdownloader = lib.recursiveUpdate
    (config.nixda.blueprints.jdownloader.mk {
      versions = { main = "v26.03.1"; };
    })
    { id = 20; };
}

Worked Example

Consider a host with three services on the private zone:

ServiceHow declaredResult under strict = falseResult under strict = true
tubearchivist-redis pinned to recommendedmk { versions = recommended; }No diagnosticNo diagnostic
tubearchivist inheritingmk { }Inheritance warningInheritance assertion (build fails)
jdownloader with a lib.recursiveUpdate overriding runtime.oci.image to a custom tagmk { versions = recommended; } plus an explicit runtime.oci.image = "my-fork/jdownloader:custom"Silent-image-override warning naming main, the composed image, and the orphan replacementSilent-image-override assertion (build fails)

Operators typically enable strict = true once every blueprint they consume has been audited and explicitly pinned. The fully-audited zone evaluates cleanly; any unaudited blueprint or downstream override surfaces immediately at evaluation time rather than after deploy.

docs/runbooks/upgrade-sandbox.md documents the per-blueprint upgrade procedure operators follow when a drift warning recommends a newer pin. ADR-0003 (Strict-Mode Boolean Global) records the design decision and its limits — Class A drift (declaration vs materialized state) is in scope for this rule; Class B post-upgrade corruption (e.g. the Twenty bodyV2 schema-migration trap recorded in the runbook) is not.

Measuring Pin Rate

The flake exposes pinningRatio :: config -> number for CI scripts and status dashboards. The return value is a real in [0.0, 1.0] — the fraction of services participating in the version-declaration contract that have explicitly pinned at least one version key. Services without a _nixda.versionDeclaration block (hand-written, legacy, non-OCI) are excluded from both numerator and denominator; an empty service graph returns 0.0.

# In a downstream consumer flake that has nixda-stack as an input:
nix eval .#lib.pinningRatio --apply 'f: f <materialized-config>'

CI integrations consume this to enforce a floor (e.g. “the platform must be at least 80% explicitly pinned”) without coupling to the per-service detail the drift diagnostics surface.


Reference: runtime.oci Options

OptionTypeDefaultDescription
imagestrOCI image reference (e.g. "nginx:latest")
environmentattrsOf str{}Container environment variables
volumeslistOf str[]Bind mounts in "host:container" format

Service-level options that affect OCI container behavior:

OptionTypeDefaultDescription
idint (1–254)Fourth IP octet; must be unique within the zone
portslistOf port[]TCP ports registered with the proxy (not published with -p)
idleTimeoutnullOr int43200Inactivity timeout in seconds; null disables