Skip to content

The NixOS module ships a working dev-image default: container.defaultImage defaults to a digest-pinned buildpack-deps:stable-scm so a stock deployment spawns container sessions out of the box, while the binary still ships none

ADR-0052 shipped the container runner with "no default image on purpose — the operator owns the userland." ADR-0053 (#207) demoted the global --container-image from a required knob to an optional default and moved the no-dev-image condition from a preflight hard-fail to a spawn-time refusal, because only at spawn is the repo — and therefore its effective image — known. ADR-0054 (#220) then made the NixOS module the opinionated deployment layer, flipping container.enable on by default and shipping default toolsImages refs, while explicitly leaving one thing open: "ADR-0053's 'defaultImage may be null' stands — this ADR adds no dev-image default." Issue #230 (2026-07-25) closes exactly that gap. A stock module deployment — services.lab.enable = true, container.enable now default-on — refused every container spawn with "no dev image for this repo …", because container.defaultImage still defaulted to null and the production host config never set it. This is the same shape ADR-0054 named for the enable switch: a default every deployment is expected to override is not a default, it is a deploy-time trap. ADR-0053's demotion is not in question; what changes here is only that the module now ships a working default, so a fresh deployment spawns container sessions with zero image config.

The pin, decided:

  • container.defaultImage defaults to a digest-pinned stock scm image, not null. The literal is docker.io/library/buildpack-deps:stable-scm@sha256:07554a82a7a29ce00a048e0b29d18f454b5721b41940d43ee3be1ef59d55b114 — the tag kept alongside the digest for readability, the same convention ADR-0053 set for repo refs, now applied to the global default. buildpack-deps:stable-scm satisfies the ADR-0052 dev-image contract as-is — shell, coreutils, git, openssh-client, curl, ca-certificates — and pulls anonymously from docker.io, so the module ships a real fallback without owning a single line of Dockerfile.

  • The binary's --container-image flag default stays empty — lab-the-binary ships no image; the module is the opinionated deployment layer. Exactly the split ADR-0054 already drew for toolsImages: the opinionated default lives upstream in nix/module.nix, not in the Go flag. Explicit defaultImage = null remains valid — the per-repo-only deployment ADR-0053 enabled (every container repo carrying its own image_ref, global unset) stays supported — and a repo's own image_ref still overrides the global regardless of what the module ships. There is still no eval assertion on the option; null is a legitimate value, not a misconfiguration.

  • Digest-pinned at implementation time, re-pinned only by a manual PR — no automation. Pin-on-review, the discipline ADR-0053 set for repo refs and ADR-0054 kept for hand-set refs. The re-pin command lives in a comment beside the literal in nix/module.nix: skopeo inspect --format '{{.Digest}}' docker://docker.io/library/buildpack-deps:stable-scm. Advancing the default is a reviewed change, never a boot-time or CI-time resolution.

  • The deploy probe gains this ref. deploy.yml's existing skopeo "wait for refs" probe (ADR-0054) now also probes the module's default dev image, so a stale or GC'd digest fails the deploy — the seam an operator is watching — instead of surfacing as spawn refusals on a fresh host at some later hour. This knowingly adds docker.io to the deploy path's probe dependencies, accepted deliberately: a default that names an unpullable digest is a broken default, and the deploy is the right place to catch it.

  • The flake checks flip to enforce the new default. The nixos-module fixtures now assert three shapes: the default unit carries the pinned ref in its ExecStart; an explicit-null fixture proves the opt-out renders no --container-image argv; and an explicit-override fixture (debian:stable-slim) proves a set value wins over the default.

Status

Accepted. Settled via issue #230 (2026-07-25), after a stock-deployment container spawn refused with "no dev image for this repo …" on a host whose config never set the global. Supersedes ADR-0053 only in that the module default is now the pinned buildpack-deps:stable-scm image instead of null; ADR-0053's substance — the global --container-image demoted to an optional default, a non-NULL per-repo image_ref overriding it, and defaultImage = null remaining valid — all stands unchanged. Continues ADR-0054's module-as-deployment-layer pattern: ADR-0054 flipped container.enable on and defaulted toolsImages while explicitly deferring the dev-image default ("this ADR adds no dev-image default"); this ADR closes that one deferred default with the same discipline (default upstream in the module, binary ships none, deploy-probe-gated, digest-pinned by manual PR). ADR-0052's runner — mount inventory, argv, and refuse-before-claim semantics — is untouched.

Considered options

  • A fallback image baked into the Go binary (--container-image flag default non-empty). Rejected: it violates the operator-owns-the-image contract ADR-0052 drew and ADR-0054 reaffirmed — lab-the-binary shipping an image makes it responsible for an image it does not build, and a flag default would ship unpinned (a bare tag), reintroducing exactly the moving-target problem ADR-0053's pin-on-save exists to close. The opinionated default belongs in the deployment layer, where it can be a digest.

  • Resolving or pinning the global ref at startup. Rejected: pin-on-boot is not pin-on-review — a startup resolution captures whatever the tag points at the moment lab happens to boot, so what runs was never seen by anyone, and boot would then depend on registry reachability, turning a docker.io blip into a failed start. The digest is fixed in a reviewed PR precisely so what runs is exactly what was reviewed.

  • A Cloonar-built dev image as the default. Rejected: it would mean owning a Dockerfile and a publish pipeline for a fallback that per-repo image_ref refs already override the moment a repo cares. A stock upstream image satisfies the contract as-is at zero maintenance. Revisit only if agents routinely need a richer userland the stock image lacks (e.g. rg/jq) — at which point a built image earns its pipeline; until then it does not.

  • Setting the value only in the host-config repo. Rejected: it structurally reproduces issue #230 — the same cross-repo trap ADR-0054 named for the enable switch. The repo that ships container support cannot rely on a different repo remembering to set a default; the default belongs upstream in the module, the one channel this repo controls end to end.

Consequences

  • A stock services.lab.enable = true deployment spawns container sessions out of the box — with container.enable default-on (ADR-0054) and now a working defaultImage, a repo flipped to runner = container runs immediately, no host-config image step.
  • docker.io joins the deploy path's probe dependencies. The skopeo wait now resolves the default dev image too; a stale or unavailable digest fails the deploy loudly rather than each fresh host's spawns quietly.
  • Advancing the default is a manual PR. No automation re-pins it; the skopeo inspect command sits in a comment beside the literal in nix/module.nix.
  • defaultImage = null is the explicit opt-out. A per-repo-only deployment sets it null and gets no --container-image argv — the ADR-0053 posture, now the deliberate opt-out rather than the resting default.
  • The binary still ships no image. --container-image's flag default stays empty; nothing about lab-the-binary changes.
  • The docs' qualifying-image example changes. docs/ops.md's dev-image section names buildpack-deps:stable-scm as the ref that qualifies as-is — the stock image the module now defaults to — where the prior example, debian:stable-slim, never satisfied the full contract without added packages (git, an ssh client, curl). The example that ships is now an example that actually works.