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
| Option | Description |
|---|---|
image | Any OCI image reference, e.g. "nginx:latest" or "ghcr.io/org/app:v1". |
environment | Key-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:
| Value | Behavior |
|---|---|
| Integer (seconds) | Container stops after this many seconds of inactivity. Default: 43200 (12 hours). |
null | Container 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:
| Diagnostic | When it fires | Severity |
|---|---|---|
| Inheritance | Consumer called mk { } and inherited every key from the blueprint’s recommended map. | Warning |
| Divergence | Consumer pinned a key to a value that differs from recommended.<key>. | Warning |
| Silent image override | Downstream code rewrote runtime.oci.image (or a pod-member image) to a string the blueprint did not compose. | Warning |
| Partial pin | Consumer supplied a versions attrset whose key set is a strict subset of recommended. | Throw — evaluation fails |
| Unknown key | Consumer 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:
| Service | How declared | Result under strict = false | Result under strict = true |
|---|---|---|---|
tubearchivist-redis pinned to recommended | mk { versions = recommended; } | No diagnostic | No diagnostic |
tubearchivist inheriting | mk { } | Inheritance warning | Inheritance assertion (build fails) |
jdownloader with a lib.recursiveUpdate overriding runtime.oci.image to a custom tag | mk { versions = recommended; } plus an explicit runtime.oci.image = "my-fork/jdownloader:custom" | Silent-image-override warning naming main, the composed image, and the orphan replacement | Silent-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
| Option | Type | Default | Description |
|---|---|---|---|
image | str | — | OCI image reference (e.g. "nginx:latest") |
environment | attrsOf str | {} | Container environment variables |
volumes | listOf str | [] | Bind mounts in "host:container" format |
Service-level options that affect OCI container behavior:
| Option | Type | Default | Description |
|---|---|---|---|
id | int (1–254) | — | Fourth IP octet; must be unique within the zone |
ports | listOf port | [] | TCP ports registered with the proxy (not published with -p) |
idleTimeout | nullOr int | 43200 | Inactivity timeout in seconds; null disables |