Build and publish Docker images as Dist::Zilla release artifacts using
API::Docker — no docker CLI shell-outs.
When releasing a Perl distribution, you often want to ship a Docker image
alongside it. This plugin integrates the image build directly into the
Dist::Zilla build / release workflow:
- No CLI dependency — uses
API::Dockerto talk to the Docker daemon directly. - No shell quoting bugs — all parameters passed as structured data.
- Streaming progress — real-time Docker build output via callbacks.
- Template expansion — tags, build args and labels use Dist::Zilla template
variables (
%v,%n,%g, ...).
cpanm Dist::Zilla::Plugin::Docker::API[Docker::API]
image = ghcr.io/example/my-app
dockerfile = Dockerfile
tag = latest
tag = %vdzil buildbuilds the image and applies everytaglocally.dzil releasere-tags the already-built image and (by default) pushes it.
The same tag list is used in both phases — there is no separate
build-vs-release list.
| Dzil command | Docker behavior |
|---|---|
dzil build |
Build image, apply every tag. No push. |
dzil release |
Re-tag the already-built image with every tag, push (if release_push=1). |
Builds and pushes go through API::Docker,
which speaks the Docker Engine HTTP API over a socket. No docker binary is
involved at any point, so any engine serving that API will do and Docker itself
need not be installed. Podman's rootless socket is a tested alternative:
systemctl --user enable --now podman.socket
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock"The socket is located from DOCKER_HOST, falling back to
/var/run/docker.sock and nothing else — Docker contexts are not consulted,
so a daemon picked with docker context use will not be found. target
reaches the engine unchanged, so multi-stage builds behave the same either way.
Since after_build builds an image unconditionally, the plugin verifies in
before_build that an engine actually answers, and gives up there instead of
after a whole distribution has been assembled:
[Docker::API] Docker::API engine ready: Podman Engine 5.4.2 (API 1.41)
During dzil release (when release_enabled and release_push are both
true), the same before_build hook also pre-flights the registry credential
the push would use — handed to the engine's POST /auth before anything is
built, so an expired or rejected login is caught before the image is built
and tagged, not after. A plain dzil build never triggers this. No
credential resolved for the registry is not a failure — an anonymous push is
legal, so nothing is checked. A failed check is fatal, and its message says
only that the check failed, not that the credential was rejected: Podman
answers a rejected credential and an unreachable registry with the same
500, so the two can't be told apart from the status, and the engine's own
text is included instead.
| Variable | Effect |
|---|---|
DOCKER_HOST |
Socket or URL of the engine to talk to |
DZIL_DOCKER_API_SKIP_PRECHECK=1 |
Skip both the engine version probe and the release registry-credential check; an unreachable engine or a bad credential then only surfaces once the build (or push) reaches it |
DZIL_DOCKER_API_SKIP=1 |
Skip the image build entirely for one run — no engine contact, no image. dzil release refuses to run with this set |
image— full image repository name, e.g.ghcr.io/user/my-app.
tag is multi-value and template-enabled. Default is latest, %V and %v
(three tags). Setting tag yourself replaces that list, it does not add
to it.
tag = latest
tag = %v
tag = v%v
tag = build-%v-%gAvailable template variables:
| Variable | Description | Example |
|---|---|---|
%n |
Distribution name | My-App |
%v |
Distribution version | 1.234 |
%V |
Major version (leading digits) | 1 |
%vmaj |
Same as %V |
1 |
%vmin |
Minor version (second field) | 234 |
%t |
Trial suffix | -TRIAL or empty |
%g |
Short git SHA (7 chars) | a1b2c3d |
%G |
Full git SHA (40 chars) | a1b2c3d4e5f6... |
%b |
Git branch name | main |
%d |
Dist::Zilla build root | /path/to/build |
%o |
Source repository root | /path/to/repo |
%a |
Release archive path | /path/to/x.tgz |
%p |
Plugin instance name | Docker::API |
release_push = 1 # push during release (default: 1)dockerfile = Dockerfile # name of the Dockerfile in the build root
pull = 0
no_cache = 0
rm = 1
force_rm = 1
platform = linux/amd64 # exactly one; a second value is a fatal error| Context mode | Description |
|---|---|
build |
Tar of Dist::Zilla's build_root |
source |
Tar of the source repository |
archive |
The release tarball |
Both build_arg and label are multi-value and template-enabled
(KEY=VALUE form).
build_arg = DIST_NAME=%n
build_arg = DIST_VERSION=%v
label = org.opencontainers.image.title=%n
label = org.opencontainers.image.version=%vfail_if_tag_exists = 0 # abort the release if any tag already exists remotely (default: 0)
skip_latest_on_trial = 1 # omit `latest` for trial releases (default: 1)fail_if_tag_exists asks the registry, not the local daemon, whether each
tag already exists — through API::Docker's distribution->exists
(GET /distribution/{name}/json), using the credential resolved for image
or an anonymous request when none applies. The check runs before anything is
tagged or pushed, and only when release_push is also on.
An engine with no /distribution route — rootless Podman among them — can't
answer that question at all, and that is treated as a release-stopping
failure rather than as "the tag is free": the release aborts with the
engine's own error and a reminder that fail_if_tag_exists = 0 releases
without the check. Turning the switch on against such an engine means every
release fails until it's turned back off.
Credentials are resolved from an ordered list of auth files — for the target registry, the first file with a usable entry wins, and a file with none falls through to the next, so a Docker config and a Podman auth file can each cover different registries in the same run:
REGISTRY_AUTH_FILE, if set — the path it names, directly.config.jsonin the directory named byDOCKER_CONFIG, if set.$XDG_RUNTIME_DIR/containers/auth.json, ifXDG_RUNTIME_DIRis set.containers/auth.jsonunderXDG_CONFIG_HOME, or under~/.configwhen that is unset.~/.docker/config.json.
~/.dockercfg (Docker's legacy pre-config.json format) is never read.
All five share the same auths block, and within the matching registry's
entry the selection is the same regardless of which file supplied it, in
this order: an identitytoken, a base64 auth field
(username:password), or plain username / password fields. For Docker
Hub the usual spellings (https://index.docker.io/v1/ and /v2/,
index.docker.io, docker.io) are all tried.
A plain docker login or podman login is picked up with no extra setup:
Podman's own default auth file is candidate 3 above. If
podman login --authfile PATH was used to write somewhere else, set
REGISTRY_AUTH_FILE=PATH in the environment dzil runs in so candidate 1
finds it.
A credsStore or credHelpers entry
delegating the secret to an external helper is not supported: the
auths entry is then empty, no credential is found, and the push is
attempted anonymously. The release precheck says so out loud before
building:
[Docker::API] Docker::API: no registry credentials found for ghcr.io/user/my-app - the release push will be anonymous
There is no DOCKER_AUTH_CONFIG support — that variable is read nowhere in
this plugin.
In a distribution that uses
@Author::GETTY,
the bundle exposes a subsection that constructs a Docker::API plugin per
occurrence:
[@Author::GETTY]
docker_image = registry/app
docker_tags = latest %v
[@Author::GETTY::Docker / runtime-root]
target = runtime-root
[@Author::GETTY::Docker / runtime-user]
target = runtime-user
tags = user
local = 1See the bundle docs for the full inheritance model.
The following config keys still work but emit a deprecation warning:
| Deprecated | Replacement |
|---|---|
build_tag |
tag |
release_tag |
tag |
repository |
image |
push |
release_push |
When build_tag and/or release_tag are given, their values are merged
(build first, then release) into tag. If tag is also set explicitly,
it wins and the legacy values are ignored.
phase, build_load, release_load and load are no longer supported and
have no replacement: they are warned about and discarded. The image is built
through the engine API, where it lands in the engine's image store anyway.
build_image returns a Dist::Zilla::Plugin::Docker::API::Result:
my $result = $plugin->client->build_image(...);
$result->image_id; # Docker image ID
$result->tags; # ArrayRef of applied tags
$result->pushed; # ArrayRef of successfully pushed tags
$result->digest; # SHA256 digest of pushed image
$result->warnings; # ArrayRef of non-fatal errorsHelper classes:
Dist::Zilla::Plugin::Docker::API::TagTemplate— template variable expansionDist::Zilla::Plugin::Docker::API::Client—API::DockeradapterDist::Zilla::Plugin::Docker::API::Result— build/push result
Roles consumed:
Dist::Zilla::Role::PluginDist::Zilla::Role::AfterBuildDist::Zilla::Role::Releaser
Copyright (c) 2026 Torsten Raudssus https://raudssus.de/.
This is free software; you can redistribute it and/or modify it under the same terms as Perl 5 itself.