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:/ci:/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:

  1. Docker itself is installed, with the Compose plugin (docker compose ..., not the old standalone docker-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.

  2. The daemon is running, and your user can reach it without sudo. On Linux that usually means being in the docker group (sudo usermod -aG docker $USER, then log out and back in) — running every docker command as root instead works too, but nothing in denver assumes it. Docker Desktop handles this for you.

  3. 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: docker on PATH) — the docker executable.

  • authentication (default true) — true, false or "may-fail", for every registries: entry (for one without username:/password:, see stored login and netrc below). An entry can set its own authentication:, which wins over this one.

    • true: denver runs docker login before it checks the registry, and a failed login stops the run.

    • false: denver never runs docker login. The registry is still checked, which works for a public image; otherwise the check fails and the registry counts as a miss. Use this when you work offline, have no credentials, or the registry is down.

    • "may-fail": like true, but a failed login is warned about and the registry counts as a miss instead of stopping the run. The normal rules for a miss then apply: the next registry is tried, then a local image is used or the image is built, and denver stops if the image is found nowhere and compose.build: false.

    Like any config key it can be set without editing the config file, e.g. -c docker.authentication=false.

  • registries (default []) — ordered list of {url, username, password, authentication} entries to check, each as <url>/<image>, once compose.image: has missed locally (or --force is set — see below), before ever considering a build: each entry in turn, via docker manifest inspect — never a real docker pull — first hit wins and no further entry is tried. On a hit, $DENVER_DOCKER_IMAGE is repointed from the bare compose.image: tag to that entry’s full <url>/<image> ref, so the actual pull happens lazily, later, whenever docker compose run itself needs the image. Empty (the default) disables this entirely. If nothing is found anywhere and compose.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 (unless authentication: says otherwise, see above). Under --verbose, every check is echoed (+ docker manifest inspect <url>/<image>) followed by a registry hit:/registry miss: line, and a miss also prints the check’s own stderr (auth error, unknown tag, …) indented below it. 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.

    An entry without username:/password: looks for credentials in this order: the login docker has stored (~/.docker/config.json, or $DOCKER_CONFIG), then the machine entry for the host in $NETRC (default ~/.netrc).

    • No stored login, but a netrc entry: denver runs docker login <host> with it (password via stdin) on every run. A failed login stops the run — or, with authentication: "may-fail", the registry is a miss.

    • Neither: the registry is taken as public, no login.

    • Stored login, only under --force: denver tests it with a non-interactive docker login <host>, so an expired token shows up early, not later as “pull access denied”. If docker rejects it, denver tries the netrc entry, then on a terminal runs docker login <host> for you (up to 3 tries). If all fail, the run stops — or, with authentication: "may-fail", the registry is a miss.

    • Registry not reachable (offline, proxy/DNS error, server down): not a rejection. The manifest check runs and misses, so denver falls back to a local image or a build.

    authentication: false skips all of this. The stored-login test is also skipped under --ci. Only hosts from registries: are used. A netrc stage before docker exports NETRC, so its file is used.

  • compose.file (required) — a single path or a list for multiple -f overlays — never guessed, see “Explicit over implicit” in ../concepts/philosophy.md.

  • compose.service (default "dev") — the compose service run/build target.

  • compose.build (default true) — 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 real docker.compose: key, shown in --show-config-full even when unset. When it (or the top-level command:, or the plain $SHELL/bash fallback) names a bare bash/zsh/fish, denver wires up denver complete for 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 whenever compose.image: is set, whether or not registries: is also configured; a hit skips the build entirely (--force overrides this, see below). Exported as $DENVER_DOCKER_IMAGE before build/run, so the compose file can say image: "${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_IMAGE is an empty string, the local check never runs, and registries: is silently ignored (nothing to search a registry for without a tag) rather than an error.

  • compose.run-args (default ["--rm"]) — extra docker compose run args.

  • compose.check-bind-mounts (default true) — check that every bind mount source of the service exists on the host before build/run, and stop if one is missing. Without this, docker creates the missing path as a root-owned directory (e.g. ~/.gitconfig becomes a directory). The mounts are read from docker compose config. Create needed paths in a hooks: pre-<stage>: script, or set false to turn the check off. Skipped with --fast (it is slow).

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 without compose.image:. denver never calls docker compose build unless compose.image: is set — with no tag to check next time, it would just rebuild on every single run, defeating the point. Set compose.image: to get denver’s own build-once behavior: it builds the first time, then a local (or registries:) hit skips the build on every run after that. Without compose.image:, compose build/compose run are left to the compose file’s own build: section to sort out, exactly as if denver’s docker provider weren’t managing images at all. Set compose.build: false for the opposite of the compose.image:-managed case: nothing gets built, ever, denver just relocates into whatever image is already there — e.g. one pulled as part of a hooks: 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 the docker CLI operates. wrap() (turn the resolved command into docker compose run ...) is what actually relocates execution. Each prints its own progress banner (--verbose only, see CLI Arguments) — setup()’s prepare and then whichever of “build”/”found locally”/”found on a registry”/skipped applies, wrap()’s run last — so the relocation itself is visible under -v, not silent between the last setup() line and the container’s own output.

  • --fast has no effect here. Unlike uv/conan/zephyr, this provider doesn’t thread --fast through setup() at all — compose.build: is read exactly as configured, so a real docker compose build still runs under --fast if 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.

  • --force is the escape hatch back to “always rebuild”: it ignores a locally-cached image: hit, though a registries: 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-run prints the docker compose build and docker compose run commands 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 the docker login that 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, using docker manifest inspect to 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 to docker compose run itself, once denver has pointed $DENVER_DOCKER_IMAGE at whichever ref (local tag or a specific registry’s) should be used.

  • Automated per-registry login, inline. Each registries: entry can carry its own username:/password: right alongside its url:, so a private registry in the search list doesn’t need a separate manual login step (e.g. a scripts: login: entry run via denver 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.

  • A registry is never required to log in. The registry is only checked when the local image is missing (or with --force), and a run that does not need it should not stop because its login fails — the registry is down, you are offline, have no credentials, or your token has expired. authentication: false or "may-fail" covers these cases without removing username:/password: from the config. Same values and meaning as the conan provider’s authentication:.