Buildx builders, outputs, cache and Bake

Builders and drivers, --load and --push, cache export and Bake files.

Intermediate16 min · lesson 5 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (7 files, 1 KB): buildx.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/buildx.tar.gz && tar -xzf buildx.tar.gz, which creates ~/lab/buildx/. SHA-256: fbbab627f7dab04589c11e1f51187582cfb5af956536f0d1e8f76c7b918d68b1

Buildx sends every build to a builder, every builder has a driver, and the driver decides where results and cache can go. That one chain explains most build surprises in CI: a cache export the builder refuses, a green build that pushed nothing, a docker buildx bake whose targets nobody can locate. This lesson runs each piece on the lab VM against a local registry:3: builders and drivers, the outputs, cache export and import, and Bake.

Use the main lab VM, secopslog-docker, with the lesson files in ~/lab/buildx: a small Go module with two programs (cmd/api and cmd/worker), one Dockerfile that builds either through ARG APP, a buildkitd.toml, and a docker-bake.hcl. Nothing on the host changes. The outputs below are BuildKit's plain progress format, what CI logs show; in an interactive terminal you get the same steps as a collapsing view, or add --progress=plain.

Dockerfile
FROM golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 AS build
ARG APP=api
WORKDIR /src
RUN --mount=type=bind,target=. \
--mount=type=cache,id=lab-shop-go-build,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/${APP}
FROM scratch
COPY --from=build /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]

Builders and drivers

A builder is a named BuildKit instance Buildx can send builds to. Every Docker host has one called default:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker buildx ls
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS default* docker \_ default \_ default running v0.33.1 linux/arm64
$ docker buildx inspect default
Name: default Driver: docker Last Activity: 2026-10-08 02:05:29 +0000 UTC Nodes: Name: default Endpoint: default Status: running BuildKit version: v0.33.1 Platforms: linux/arm64 Labels: ... GC Policy rule#0: All: false Filters: type==source.local type==exec.cachemount type==source.git.checkout Keep Duration: 48h0m0s Max Used Space: 527.1MiB ...

The docker driver runs BuildKit inside dockerd. Its worker uses containerd and the overlayfs snapshotter, the same store the daemon's images live in, which is why builds on the default builder can use local images as bases and land in docker image ls without a copy. The GC policy lines are the default garbage-collection rules for build cache (cache mounts and local sources are kept 48 hours within a size cap, the rest within free-space limits). The docker driver has no configuration of its own: no buildkitd.toml, no BuildKit version choice, and it builds for the host's platforms only unless emulation is installed ("Multi-platform images").

The docker-container driver runs a separate BuildKit daemon in a container, from the moby/buildkit image. Two other drivers exist for shared build infrastructure: kubernetes starts BuildKit pods in a cluster (docker buildx create --driver kubernetes --driver-opt namespace=ci,replicas=2), and remote connects to a BuildKit daemon you already run (docker buildx create --driver remote tcp://buildkitd.internal:1234, normally with TLS client certificates). Both behave like docker-container in everything below.

buildkitd.toml
# Pull Docker Hub images through Google's public mirror (avoids Docker Hub's
# anonymous pull limit on shared CI runners) and talk plain HTTP to the lab registry.
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
[registry."localhost:5000"]
http = true

This configuration does two things. Builds pull Docker Hub images through mirror.gcr.io, Google's public cache of frequently pulled Docker Hub images, which takes most pulls off a shared CI egress IP and so away from Docker Hub's anonymous pull limit. Images the mirror does not hold still come from Docker Hub and count against the limit, so authenticated pulls or your own pull-through cache remain the complete answer. And the builder talks plain HTTP to the lab registry on localhost:5000; dockerd allows that for localhost by default, a separate BuildKit daemon needs it spelled out. Create the builder:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker buildx create --name lab-builder --driver docker-container \ --driver-opt network=host --buildkitd-config buildkitd.toml --bootstrap
#1 [internal] booting buildkit #1 pulling image moby/buildkit:buildx-stable-1 #1 pulling image moby/buildkit:buildx-stable-1 2.1s done #1 creating container buildx_buildkit_lab-builder0 #1 creating container buildx_buildkit_lab-builder0 1.6s done #1 DONE 3.9s lab-builder
$ docker buildx ls docker ps --filter name=buildx_buildkit --format "{{.Names}} {{.Image}} {{.Status}}"
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS lab-builder docker-container \_ lab-builder0 \_ unix:///var/run/docker.sock running v0.33.1 linux/arm64 default* docker \_ default \_ default running v0.33.1 linux/arm64 buildx_buildkit_lab-builder0 moby/buildkit:buildx-stable-1 Up 1 second
$ docker buildx inspect lab-builder | head -15 docker exec buildx_buildkit_lab-builder0 cat /etc/buildkit/buildkitd.toml
Name: lab-builder Driver: docker-container Last Activity: 2026-10-08 02:05:36 +0000 UTC Nodes: Name: lab-builder0 Endpoint: unix:///var/run/docker.sock Driver Options: network="host" Status: running BuildKit daemon flags: --allow-insecure-entitlement=network.host BuildKit version: v0.33.1 Platforms: linux/arm64 Labels: org.mobyproject.buildkit.worker.executor: oci org.mobyproject.buildkit.worker.hostname: secopslog-docker [registry] [registry.'docker.io'] mirrors = ['mirror.gcr.io'] [registry.'localhost:5000'] http = true

--bootstrap started it right away instead of on first use. The builder is the container buildx_buildkit_lab-builder0, and its state (cache, snapshots) lives in a volume next to it. --driver-opt network=host puts the container in the VM's network namespace, so localhost:5000 in the builder is the VM's port 5000; Buildx grants the matching network.host entitlement on the daemon flags. The config file was copied into the container at creation, so later edits to your copy need docker buildx rm and create again. The asterisk in docker buildx ls still marks default as the builder plain docker build uses.

Where the result goes

A docker-container builder has its own cache and no image store. Build without saying where the result should go:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker buildx build --builder lab-builder -t lab-api:1 .
#0 building with "lab-builder" instance using docker-container driver ... #5 sha256:125215c6056cfe0a165d14de8a72a3546ec1167a4468ebcd93c0f403469ef15d 53.48MB / 67.65MB 40.2s ... #5 DONE 51.4s #5 [build 1/3] FROM docker.io/library/golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 #5 extracting sha256:35df002bd9011b0379adcf4733094a3adc75f7fd382eb9ac6ec7f5c5470a852f 0.0s done #5 extracting sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1 0.0s done #5 DONE 51.5s #6 [build 2/3] WORKDIR /src #6 DONE 0.7s #7 [build 3/3] RUN --mount=type=bind,target=. --mount=type=cache,id=lab-shop-go-build,target=/root/.cache/go-build CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/api #7 DONE 9.5s #8 [stage-1 1/1] COPY --from=build /out/app /app #8 DONE 0.0s WARNING: No output specified with docker-container driver. Build result will only remain in the build cache. To push result image into registry use --push or to load image into docker use --load

The FROM step downloaded the whole golang image (51 seconds here) even though the daemon already had it: this builder does not share the daemon's store, so it pulls through its own registry configuration. The final warning is the point. The image exists only as cache inside the builder. You choose an output:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker buildx build --builder lab-builder --load -t lab-api:1 .
#0 building with "lab-builder" instance using docker-container driver ... #9 exporting manifest sha256:2cb9c78938fa3fcc41ea63a881fe6b81e2d05a5a3a9f2096605cd8ac8bee331d done #9 exporting config sha256:7d3e59b964988d3f66bfe3b2e743bd6365fff79b8d420f5de19adb284f50b682 0.0s done #9 sending tarball #9 sending tarball 0.3s done #9 DONE 0.5s #10 importing to docker #10 DONE 0.0s
$ docker image ls lab-api
IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-api:1 2cb9c78938fa 7.73MB 2.28MB

--load exported to a tarball ("sending tarball") and the daemon imported it. The image ID, 2cb9c78938fa, is the manifest digest shown in the export: a loaded image from this builder is a single manifest with no attestation. That differs from the default builder, where the image goes straight into the containerd image store and keeps its provenance attestation as an index ("Image anatomy: index, manifest, config and layers" shows one). Pushing keeps everything the builder produced. Start the lab registry and push:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker run -d --name lab-registry -p 127.0.0.1:5000:5000 registry:3
a6f8c258f98bf9a9e232ae656ccc1b0a271758fc66a33b37fbf851aa3b535ff5
$ docker buildx build --builder lab-builder --push -t localhost:5000/lab-api:1 .
#0 building with "lab-builder" instance using docker-container driver ... #9 exporting to image #9 exporting layers done #9 exporting manifest sha256:2cb9c78938fa3fcc41ea63a881fe6b81e2d05a5a3a9f2096605cd8ac8bee331d done #9 exporting config sha256:7d3e59b964988d3f66bfe3b2e743bd6365fff79b8d420f5de19adb284f50b682 done #9 exporting attestation manifest sha256:21ec790d0f192a02beea9e3f9b2353e833335bc77d9572c9f2126cb77b528bd2 #9 exporting attestation manifest sha256:21ec790d0f192a02beea9e3f9b2353e833335bc77d9572c9f2126cb77b528bd2 0.0s done #9 exporting manifest list sha256:d19dfc45323899f73106dca119db2eec9781414081757364a9bce0cfc10ab492 0.0s done #9 pushing layers 0.1s done #9 pushing manifest for localhost:5000/lab-api:1@sha256:d19dfc45323899f73106dca119db2eec9781414081757364a9bce0cfc10ab492 #9 pushing manifest for localhost:5000/lab-api:1@sha256:d19dfc45323899f73106dca119db2eec9781414081757364a9bce0cfc10ab492 0.0s done #9 DONE 0.2s

Same manifest digest as the loaded image, plus an attestation manifest (BuildKit's default minimal provenance) and the manifest list (the index) that ties them together. The index digest is what the registry tag now points to. SBOM and provenance attestations are build outputs you turn on and tune with --sbom and --provenance; "Pinning, SBOMs, provenance and scanning" in Advanced container security covers what they contain and how to use them.

docker buildx use changes the builder that docker build and docker buildx build pick when --builder is not given. It is a per-user setting in ~/.docker/buildx, and a common cause of "my build suddenly does not show up in docker images":

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker buildx use lab-builder docker build -q -t lab-api:1 . docker buildx use default
sha256:19d0dba1a7499fcfedb3b1171863d2c011af53dc5d6cd5c3e0f605be752711ae

Here docker build ran on lab-builder and did not warn about a missing output, because Buildx turns on --load by default when it is invoked as docker build. docker buildx build does not do that, as the earlier warning showed. Switching back with docker buildx use default keeps the rest of the course predictable. In scripts, pass --builder explicitly or set BUILDX_BUILDER instead of depending on use.

The filesystem exporters skip images entirely. Build the worker and take the binary straight out of the final stage:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker buildx build --builder lab-builder -q --build-arg APP=worker -o type=local,dest=out . ls -l out ./out/app
total 1540 -rwxr-xr-x 1 ubuntu ubuntu 1573024 Oct 8 07:36 app worker on linux/arm64: no jobs queued
$ docker buildx build --builder lab-builder -q -o type=tar,dest=api.tar . tar -tvf api.tar
-rwxr-xr-x 0/0 5439648 2026-10-08 07:36 app
$ docker buildx build --builder lab-builder -q -o type=oci,dest=api-oci.tar -t lab-api:1 . tar -tf api-oci.tar tar -xOf api-oci.tar index.json | jq '.manifests[] | {mediaType, digest, platform}'
sha256:7d3e59b964988d3f66bfe3b2e743bd6365fff79b8d420f5de19adb284f50b682 blobs/ blobs/sha256/ blobs/sha256/2cb9c78938fa3fcc41ea63a881fe6b81e2d05a5a3a9f2096605cd8ac8bee331d blobs/sha256/7d3e59b964988d3f66bfe3b2e743bd6365fff79b8d420f5de19adb284f50b682 blobs/sha256/cd6163f3e6bec1e0f76e64e4ae5079a85bfebb65f4b722ec1c7b16147d938934 index.json oci-layout { "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:2cb9c78938fa3fcc41ea63a881fe6b81e2d05a5a3a9f2096605cd8ac8bee331d", "platform": { "architecture": "arm64", "os": "linux" } }

type=local wrote the 1.5MB static worker binary into out/, and it runs directly on the VM. type=tar holds the same kind of filesystem (here the 5.4MB api binary) without any image metadata. type=oci is a complete image in OCI layout: index.json points at the manifest, and the blobs are the manifest, config and one layer. Tools such as Trivy, Syft and Cosign read that layout directly. Like --load, the OCI archive from this builder carries no attestation manifest.

Cache export and import

A fresh CI runner, a new builder or a pruned one starts with an empty cache. Exporting the cache to a registry and importing it on the next build fixes that. mode=max exports cache for every stage, not only the layers of the final image:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ time docker buildx build --builder lab-builder -q --push -t localhost:5000/lab-api:1 \ --cache-to type=registry,ref=localhost:5000/lab-api:buildcache,mode=max .
sha256:f14efe1e22d235d5274ced95ad005dc11c78cf5183b1e05c3f607ae41c873d26 real 0m1.078s user 0m0.065s sys 0m0.069s
$ docker buildx imagetools inspect --raw localhost:5000/lab-api:buildcache | jq '{config: .config.mediaType, layers: [.layers[].size]}'
{ "config": "application/vnd.buildkit.cacheconfig.v0", "layers": [ 67652866, 93, 127, 249811, 32, 4187659, 2284263, 2282639 ] }

The cache lives next to the image as lab-api:buildcache, a manifest whose config type is application/vnd.buildkit.cacheconfig.v0. Its largest layer, 67,652,866 bytes, is the golang base layer; with mode=max the builder stage is in the cache, base included. Now throw the builder's local cache away and rebuild with --cache-from:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker buildx prune --builder lab-builder -af | tail -1
Total: 482.4MB
$ time docker buildx build --builder lab-builder --progress=plain --push -t localhost:5000/lab-api:1 \ --cache-from type=registry,ref=localhost:5000/lab-api:buildcache . 2>&1 | grep -E "^#[0-9]+ (\[|CACHED|importing)"
#1 [internal] load build definition from Dockerfile #2 [internal] load metadata for docker.io/library/golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 #3 [internal] load .dockerignore #4 [build 1/3] FROM docker.io/library/golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 #5 importing cache manifest from localhost:5000/lab-api:buildcache #6 [internal] load build context #7 [build 3/3] RUN --mount=type=bind,target=. --mount=type=cache,id=lab-shop-go-build,target=/root/.cache/go-build CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/api #7 CACHED #8 [build 2/3] WORKDIR /src #8 CACHED #9 [stage-1 1/1] COPY --from=build /out/app /app #9 CACHED real 0m2.023s user 0m0.073s sys 0m0.096s

482.4MB of local cache gone, and the rebuild still reports every step CACHED after importing cache manifest, in 2.0 seconds. BuildKit downloaded only the cache metadata; because nothing had to run, it never fetched the golang layers either. Compare the two modes with the local cache backend, which writes the same format to a directory:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker buildx build --builder lab-builder -q -o type=cacheonly --cache-to type=local,dest=cache-min,mode=min . docker buildx build --builder lab-builder -q -o type=cacheonly --cache-to type=local,dest=cache-max,mode=max . du -sh cache-min cache-max
2.3M cache-min 74M cache-max

2.3MB for mode=min, the layers of the final scratch image only, against 74MB for mode=max. With min, a runner whose source changed still has to pull golang and recompile; with max, it reuses every unchanged step of the builder stage, at the price of a bigger upload. Cache mounts (RUN --mount=type=cache) are never exported by either mode. Other backends follow the same pattern: type=gha stores cache in the GitHub Actions cache service and only works inside a workflow, type=s3 and type=azblob use object storage, and type=inline embeds min-mode cache metadata in the pushed image.

The default builder can export cache too, as long as the daemon uses the containerd image store, the default on fresh Docker 29 installs and the lab VM's configuration:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker buildx build --builder default -q -o type=cacheonly --cache-to type=local,dest=cache-default,mode=max . du -sh cache-default
74M cache-default

74MB, the same size as the mode=max export from lab-builder, written by BuildKit inside dockerd. On hosts still on the classic store, any --cache-to other than inline fails on the default builder with Cache export is not supported for the docker driver. The usual fix in CI is a docker-container builder, which is what docker/setup-buildx-action creates by default and which works whatever store the runner's daemon uses.

Bake

A real repository builds several images with the same options: platforms, labels, cache settings, tags derived from the commit. Writing those as shell flags in each pipeline drifts. Bake reads them from a file, docker-bake.hcl, and builds the targets in parallel on one builder:

docker-bake.hcl
variable "REGISTRY" {
description = "Registry the images and cache go to"
default = "localhost:5000"
}
variable "TAG" {
description = "Image tag, set by CI to the commit or release"
default = "dev"
}
group "default" {
targets = ["api", "worker"]
}
target "_common" {
context = "."
dockerfile = "Dockerfile"
labels = {
"org.opencontainers.image.source" = "https://example.com/lab/shop"
}
}
target "api" {
description = "HTTP API image"
inherits = ["_common"]
args = { APP = "api" }
tags = ["${REGISTRY}/lab-api:${TAG}"]
cache-from = ["type=registry,ref=${REGISTRY}/lab-api:buildcache"]
cache-to = ["type=registry,ref=${REGISTRY}/lab-api:buildcache,mode=max"]
}
target "worker" {
description = "Background worker image"
inherits = ["_common"]
args = { APP = "worker" }
tags = ["${REGISTRY}/lab-worker:${TAG}"]
cache-from = ["type=registry,ref=${REGISTRY}/lab-worker:buildcache"]
cache-to = ["type=registry,ref=${REGISTRY}/lab-worker:buildcache,mode=max"]
}

Variables have defaults and are overridden by environment variables of the same name. _common holds the shared settings; api and worker inherit it and add their build argument, tags and cache refs; the group default is what docker buildx bake builds with no target named. Ask Bake what it sees before building anything:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker buildx bake --list=targets docker buildx bake --list=variables
#1 [internal] load local bake definitions #1 reading docker-bake.hcl 1.04kB / 1.04kB done #1 DONE 0.0s TARGET DESCRIPTION api HTTP API image default api, worker worker Background worker image #1 [internal] load local bake definitions #1 reading docker-bake.hcl 1.04kB / 1.04kB done #1 DONE 0.0s VARIABLE TYPE VALUE DESCRIPTION REGISTRY localhost:5000 Registry the images and cache go to TAG dev Image tag, set by CI to the commit or release
$ docker buildx bake --print api
#1 [internal] load local bake definitions #1 reading docker-bake.hcl 1.04kB / 1.04kB done #1 DONE 0.0s { "group": { "default": { "targets": [ "api" ] } }, "target": { "api": { "description": "HTTP API image", "context": ".", "dockerfile": "Dockerfile", "args": { "APP": "api" }, "labels": { "org.opencontainers.image.source": "https://example.com/lab/shop" }, "tags": [ "localhost:5000/lab-api:dev" ], "cache-from": [ { "ref": "localhost:5000/lab-api:buildcache", "type": "registry" } ], "cache-to": [ { "mode": "max", "ref": "localhost:5000/lab-api:buildcache", "type": "registry" } ] } } }

--print shows the fully resolved definition as JSON, with inheritance applied and ${REGISTRY}, ${TAG} filled in. It is the first thing to run when a CI build does something unexpected. _common is not listed as a target because names starting with _ are treated as internal. Build and push both images with a tag from the environment:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ TAG=2 docker buildx bake --builder lab-builder --push
#0 building with "lab-builder" instance using docker-container driver ... #5 [worker] importing cache manifest from localhost:5000/lab-worker:buildcache #5 ERROR: failed to configure registry cache importer: localhost:5000/lab-worker:buildcache: not found #6 [worker build 1/3] FROM docker.io/library/golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 #6 resolve docker.io/library/golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 0.0s done #6 DONE 0.0s #7 [api] importing cache manifest from localhost:5000/lab-api:buildcache #7 inferred cache manifest type: application/vnd.oci.image.manifest.v1+json done #7 DONE 0.0s ... > [worker] importing cache manifest from localhost:5000/lab-worker:buildcache: ------
$ curl -s http://127.0.0.1:5000/v2/_catalog docker buildx imagetools inspect localhost:5000/lab-worker:2
{"repositories":["lab-api","lab-worker"]} Name: localhost:5000/lab-worker:2 MediaType: application/vnd.oci.image.index.v1+json Digest: sha256:5d9563f8a143665d8f41c7a0e1006edf2f219eaafddb7b4a7941e2aa0377505e Manifests: Name: localhost:5000/lab-worker:2@sha256:6bd3a964a56be04b9073dd46c9f03f7dfa4f7fcead79526a6abaf7a5211707e1 MediaType: application/vnd.oci.image.manifest.v1+json Platform: linux/arm64 Name: localhost:5000/lab-worker:2@sha256:a3ffee30e73d8b9e52f685867936ae2e7b82a9854c2d106770faf43bd1943ee2 MediaType: application/vnd.oci.image.manifest.v1+json Platform: unknown/unknown Annotations: vnd.docker.reference.digest: sha256:6bd3a964a56be04b9073dd46c9f03f7dfa4f7fcead79526a6abaf7a5211707e1 vnd.docker.reference.type: attestation-manifest

Both targets ran in one build session. The ERROR for the worker cache is the first build's normal state: nothing has been exported to lab-worker:buildcache yet, so the import fails, BuildKit builds without it, and the summary at the end repeats the failed import without failing the build. The api import succeeded from the cache pushed earlier. The registry now has both repositories, and lab-worker:2 is an index with its attestation, like any pushed build.

CI changes a definition without editing the file through --set target.key=value (with * for every target) and variables through the environment:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ TAG=3 docker buildx bake --print --set "*.platform=linux/amd64,linux/arm64" worker | jq '.target.worker | {platforms, tags}'
#1 [internal] load local bake definitions #1 reading docker-bake.hcl 1.04kB / 1.04kB done #1 DONE 0.0s { "platforms": [ "linux/amd64,linux/arm64" ], "tags": [ "localhost:5000/lab-worker:3" ] }

The worker now builds for two platforms with tag 3. --print shows the value as one string, linux/amd64,linux/arm64, exactly as it was set; Buildx splits the comma list into two platforms when it builds. A GitHub Actions job is usually only this much, with the bake file doing the rest. Each action is pinned to a full commit SHA with its version in a comment, for the reason "Docker in CI/CD: build to promote" gives, and the job token gets read access only:

.github/workflows/images.yml (excerpt)
permissions:
contents: read
jobs:
images:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: registry.example.com
username: ${{ vars.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
env:
REGISTRY: registry.example.com/shop
TAG: ${{ github.sha }}
with:
push: true
set: |
*.cache-from=type=gha
*.cache-to=type=gha,mode=max

setup-buildx-action creates a docker-container builder on the runner, so every output and cache backend above is available. The --set lines swap the registry cache for the GitHub cache without touching the file developers use locally. "Docker in CI/CD: build to promote" puts this build into the full pipeline with scanning, signing and promotion.

Clean up

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.8.2
$ docker buildx rm lab-builder docker rm -f -v lab-registry docker image rm lab-api:1 rm -rf out api.tar api-oci.tar cache-min cache-max cache-default
lab-builder removed lab-registry Untagged: lab-api:1 Deleted: sha256:19d0dba1a7499fcfedb3b1171863d2c011af53dc5d6cd5c3e0f605be752711ae

docker buildx rm stops the builder container and deletes its state volume with the cache in it (--keep-state keeps the volume for a later builder of the same name). rm -v on the registry removes its anonymous storage volume with the pushed images. The moby/buildkit image stays in the image store for the next builder; reset-lab.sh removes any lab- builders you forget.

Quick check
01A pipeline runs docker buildx build --builder ci -t registry.example.com/api:1.8 . on a docker-container builder. The job is green but the tag never appears in the registry. Why?
Incorrect — There was no push to reject. The registry was never contacted.
Incorrect — Pushing is what docker-container builders do best; the lab pushed from lab-builder several times.
Correct — With a non-docker driver and no --push, --load or -o, Buildx warns "No output specified" and keeps the result only as build cache.
Incorrect — The tag is used by --push. Without an output option it is simply never used.
02A team wants CI builds on fresh runners to reuse the compiled dependencies from the builder stage of a multi-stage Dockerfile. Which cache export does that?
Correct — mode=max exports cache for every stage, including the builder stage, so later runs import it with --cache-from.
Incorrect — mode=min exports only the final image's layers; in the lab that was 2.3MB, with no builder stage in it.
Incorrect — Inline cache embeds min-mode metadata in the pushed image, so the builder stage is not covered either.
Incorrect — Cache mounts stay on the builder that ran them and are never exported; a fresh runner starts with them empty.
03docker buildx bake --print shows "tags": ["localhost:5000/lab-api:dev"], but the CI log shows images pushed as :2. The bake file was not changed. What explains it?
Incorrect — Bake uses the tags in the file; it filled ${TAG} with whatever value the variable had.
Incorrect — Cache import only provides build results; it never changes tags.
Incorrect — --print shows the fully resolved definition, inheritance included. It only reflected the environment of the shell that ran it.
Correct — A Bake variable takes the value of an environment variable with the same name; the lab used TAG=2 the same way.

Try this

Work through “Clean up” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from buildx builders, outputs, cache and bake, keep “Clean up”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related