docker provider
A docker stage is a wrapper, not a builder — see “Wrapper / relocation”
in Configuration. Instead of building anything itself, it relocates the
rest of the pipeline into a docker compose service.
[my-docker-stage]
provider = "docker"
[my-docker-stage.compose]
file = "docker-compose.yml"
(provider:/description:/disabled:/depends-on:/skip-on-success:/skip-on-failure:/scripts:/
env:/env-prepend:/env-append: are generic keys every stage has —
see “Generic stage keys” in Configuration. Everything below is specific to docker.)
Requires
docker with the Compose plugin must be available on the machine this
stage runs on — denver never installs it, and every command it issues is a
docker compose ... one (v2, the plugin — not the standalone
docker-compose script). The stage checks both upfront (docker on PATH,
then docker compose version) and dies naming whichever is missing, rather
than failing deep inside the first compose build/compose run. Skipped
under --dry-run, so an env can still be previewed on a machine without
docker. The daemon has to be reachable for the invoking user, too. Point
exe: at a specific docker binary if docker isn’t the right exe.
Setting up Docker
Three things have to be true before a docker stage can run, none of which
denver does for you:
Docker itself is installed, with the Compose plugin (
docker compose ..., not the old standalonedocker-compose). On Linux, follow Docker’s own install guide for your distribution — the Compose plugin is included by default on a current install. On macOS or Windows, Docker Desktop bundles both.The daemon is running, and your user can reach it without
sudo. On Linux that usually means being in thedockergroup (sudo usermod -aG docker $USER, then log out and back in) — running everydockercommand as root instead works too, but nothing in denver assumes it. Docker Desktop handles this for you.Check it actually works, the same way this stage checks it:
docker compose version
If that prints a version instead of an error, this stage is ready to run.
Anything beyond that — a private registry login, an udev rule for a USB
device the container needs — is project-specific, not part of getting
Docker itself working. That kind of one-time setup usually belongs in an
env’s own scripts: setup: (see
scripts: in
Configuration) rather than something a reader has to remember to do by hand
before their first run.
Key reference
exe(default:dockeronPATH) — the docker executable.registries(default[]) — ordered list of{url, username, password}entries to check, each as<url>/<image>, oncecompose.image:has missed locally (or--forceis set — see below), before ever considering a build: each entry in turn, viadocker manifest inspect— never a realdocker pull— first hit wins and no further entry is tried. On a hit,$DENVER_DOCKER_IMAGEis repointed from the barecompose.image:tag to that entry’s full<url>/<image>ref, so the actual pull happens lazily, later, wheneverdocker compose runitself needs the image. Empty (the default) disables this entirely. If nothing is found anywhere andcompose.build: false, denver dies with a clear error instead of silently doing nothing.url:is required per entry;username:/password:are optional but, if either is set, both must be — when present,docker login <url>runs automatically, credentials piped via stdin (never argv, never logged), right before the manifest check against that entry; an entry with neither is assumed already-authenticated or public. Both fields go through denver’s normal${VAR}interpolation, so a literal (myusername) and an env-var-sourced secret (${DOCKER_PASSWORD_DOCKERHUB}) are written the same way.compose.file(required) — a single path or a list for multiple-foverlays — never guessed, see “Explicit over implicit” in../concepts/philosophy.md.compose.service(default"dev") — the compose servicerun/buildtarget.compose.build(defaulttrue) — whether a build is ever attempted (see below).compose.default-cmd— the fallback interactive command once relocated into the container, read by denver’s own command resolution (command:at the top level still wins over this if set) — not read by this provider itself, but still a realdocker.compose:key, shown in--show-config-fulleven when unset. When it (or the top-levelcommand:, or the plain$SHELL/bashfallback) names a barebash/zsh/fish, denver wires updenver completefor it automatically before landing there — the container’s own image, unlike the host, is never something a user already has completion set up in themselves. Anything else (extra args aside, which are preserved) is left untouched. See Shell completion for the full mechanism.compose.image— the canonical local tag denver checks for before falling back to a build — checked whenevercompose.image:is set, whether or notregistries:is also configured; a hit skips the build entirely (--forceoverrides this, see below). Exported as$DENVER_DOCKER_IMAGEbefore build/run, so the compose file can sayimage: "${DENVER_DOCKER_IMAGE}"instead of hard-coding the same tag a second time — denver doesn’t cross-check the two, this is just how they stay in sync. If unset,$DENVER_DOCKER_IMAGEis an empty string, the local check never runs, andregistries:is silently ignored (nothing to search a registry for without a tag) rather than an error.compose.run-args(default["--rm"]) — extradocker compose runargs.
Need something computed at runtime before build/run — a compose .env file,
a login, anything a static compose file can’t express? Use a hooks: pre-<stage>: script (see “Hooks and scripts” in
Configuration) rather than a docker:-specific key — it runs on the
host right before this stage’s setup(), same timing, without denver
needing a per-provider mechanism for it.
Design notes
compose.build: true(the default) does nothing withoutcompose.image:. denver never callsdocker compose buildunlesscompose.image:is set — with no tag to check next time, it would just rebuild on every single run, defeating the point. Setcompose.image:to get denver’s own build-once behavior: it builds the first time, then a local (orregistries:) hit skips the build on every run after that. Withoutcompose.image:,compose build/compose runare left to the compose file’s ownbuild:section to sort out, exactly as if denver’s docker provider weren’t managing images at all. Setcompose.build: falsefor the opposite of thecompose.image:-managed case: nothing gets built, ever, denver just relocates into whatever image is already there — e.g. one pulled as part of ahooks: pre-<stage>:script or a CI step outside denver entirely.Host vs. container.
setup()(build the image) always runs on the host — that’s the only place thedockerCLI operates.wrap()(turn the resolved command intodocker compose run ...) is what actually relocates execution. Each prints its own progress banner (--verboseonly, see CLI Arguments) —setup()’sprepareand then whichever of “build”/”found locally”/”found on a registry”/skipped applies,wrap()’srunlast — so the relocation itself is visible under-v, not silent between the lastsetup()line and the container’s own output.--fasthas no effect here. Unlike uv/conan/zephyr, this provider doesn’t thread--fastthroughsetup()at all —compose.build:is read exactly as configured, so a realdocker compose buildstill runs under--fastif the image wasn’t found locally or on a registry. The local/registries:lookup itself is cheap and read-only, so it always runs regardless.--forceis the escape hatch back to “always rebuild”: it ignores a locally-cachedimage:hit, though aregistries:entry that already has it still wins over a forced local rebuild — a rebuild only actually happens if none of the configured registries have it either.--dry-runprints thedocker compose buildanddocker compose runcommands instead of running them; no container is ever started. The local/registries:existence checks still run (they’re the read-only queries deciding whether a build would be shown at all), but thedocker loginthat would precede a private-registry check does not — so a private entry may report a miss it wouldn’t report for real. The bigger limit is structural: since the container is never entered, the setup stages that run inside it can’t be previewed. denver prints a note saying so; use--skip <docker stage>to preview that same stack on the host instead.Multi-registry check, local-first, never pulling in
setup(). When several places might already have the image — a shared registry, a personal mirror, whatever a CI run pushed to —registries:lets denver search them in a fixed order instead of only knowing about one, usingdocker manifest inspectto check existence without downloading anything. It’s still fully explicit: nothing is scanned or guessed, only the entries named in the list are ever tried, in the order given. The actual image transfer is left entirely todocker compose runitself, once denver has pointed$DENVER_DOCKER_IMAGEat whichever ref (local tag or a specific registry’s) should be used.Automated per-registry login, inline. Each
registries:entry can carry its ownusername:/password:right alongside itsurl:, so a private registry in the search list doesn’t need a separate manual login step (e.g. ascripts: login:entry run viadenver run <env> --scripts login, Configuration’s generic one-shot mechanism) — denver logs in for you, right before it’s actually needed, only for entries that carry credentials.