ADR-0010 — Engine = podman everywhere via CONTAINER_HOST; docker only as fallback¶
- Status: Accepted (2026-06, "docker vs podman" investigation; buildah build path added in PR #36) — implemented
- Sources: HANDOVER.md ("docker vs podman in the dev box", key decision 2), PLAN.md §7.1 (engine note), src/foldyard/stack.py engine/context resolution
Context¶
foldyard's substrate is a rootless podman machine (ADR-0001) — the machine lifecycle has no
docker equivalent, so podman is non-negotiable at that layer. But the dev box historically drove
the stack with the docker CLI: the machine's docker-compat socket is bind-mounted into the box
at /var/run/docker.sock, and docker version there reports a podman server. Meanwhile plain
podman inside the box defaults to broken local (nested) mode — the box's hardening blocks a
nested engine (read-only /proc/sys, no /dev/fuse; see ADR-0017). So
the question was which CLI foldyard itself should shell, and how to make one answer hold on the
Mac, in the box, and in CI.
The investigation found the missing piece: CONTAINER_HOST=unix:///var/run/docker.sock podman …
drives the machine socket natively (podman's auto remote mode), and podman compose works via
the docker-compose provider. Empirically validated in-box: podman compose ps sees the same
stack docker created; podman run --privileged --pid=host is refused; podman info reports
rootless.
Decision¶
podman is foldyard's engine everywhere, made true by exporting the machine socket under both names. Concretely:
config.engine()(src/foldyard/config.py) resolvesFOLDYARD_ENGINEenv →[engine].cli→ podman if installed, else docker. All engine calls go through the resolved CLI.stack._context()(src/foldyard/stack.py) exports the machine's socket as bothDOCKER_HOSTandCONTAINER_HOST: podman readsCONTAINER_HOST, docker readsDOCKER_HOST. That lets plainpodmanreach the engine everywhere — including the box, where local podman would otherwise be broken — while any remaining docker caller keeps working.- podman-compose is Foldyard's bundled provider; docker stays the CI fallback, not the engine.
Foldyard installs
podman-composein its tool venv and exports its absolute path throughPODMAN_COMPOSE_PROVIDER, avoiding Podman's default preference for a global docker-compose. The box image keeps a docker CLI as a compat client; on hosts with no podman at all (GitHub-hosted runners),engine()degrades to docker anddocker composeruns the same stack (ADR-0017 relies on this for the CI e2e). - Builds are concurrent native buildah jobs on the podman engine (added in PR #36,
stack._podman_build()):podman compose --buildshells out to the docker-compose provider withDOCKER_BUILDKIT=0hard-set, falling back to the classic builder — which rejects composeadditional_contexts. Sofy upresolves the build graph from plaincompose config(parsing podman-compose's normalized YAML output — no--format json), builds each independent service concurrently withpodman build(buildah handles--build-context, cache mounts, multi-stage targets natively — no BuildKit/buildx sidecar, no Docker dependency), tags images with podman-compose's exact<project>_<service>names, then runsup --no-buildso it reuses them. On the docker engine, compose's default BuildKit builder handles additional contexts, so that path never enters_podman_build. - The box ships a podman remote client pinned near the machine's version. The consumer box
image installs
podman-remoteand symlinks it topodman— that symlink is what flips the box'sengine()onto podman. The version must track the machine's (~5.8): the remote wire-format for--build-contextchanged between 5.4 and 5.8, and a stale client fails with "invalid additional build context format".
Consequences¶
- One engine story across host, box, and worktrees: foldyard echoes
"$ENGINE" …commands the user can copy-paste, and the golden tests (ADR-0017 tier 2) assert podman command sequences deterministically (conftestpinsFOLDYARD_ENGINE=podman). CONTAINER_HOSTis load-bearing. Dropping it, or makingengine()"prefer docker" again, breaks the verified in-boxup/ps/verify— this is on the HANDOVER don't-break list.- Engine-schema differences are handled where they exist rather than papered over:
verify's rootless probe tries docker's.SecurityOptionsand podman's.Host.Security.Rootless; the box-image build adds--format dockeronly under podman (so DockerfileSHELL [...]is honoured). - "Is there a podman binary?" is useless as a host test — the box has one. Host-vs-box detection
is
config.in_box()(IN_DEVBOX / preset docker-compat DOCKER_HOST), neverwhich("podman"). - The docker CLI remains in the box image as a compatibility client for consumer-owned workflows,
but Foldyard's own
podman composecalls select its bundled podman-compose provider. - Machine backends (ADR-0011) keep this decision intact: every backend hands back a libpod socket, and the same dual-name export makes both CLIs work against whichever backend is active.
Rejected alternatives¶
- docker-first
engine()(the pre-investigation state): made the box's engine identity a lie (a docker CLI talking to podman), forked behaviour between Mac and box, and left podman's native builder — the only one that can do composeadditional_contextshere — unused. - BuildKit/buildx or a
moby/buildkitsidecar for the additional-contexts problem: adds a Docker dependency and a second builder daemon where buildah already does everything needed, natively, over the existing socket. - A nested engine in the box so plain podman "just works" locally: blocked by the box's own hardening, and undesirable — the box driving the machine's engine over the socket is the isolation model (ADR-0017).