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: f03ac548eccb397542a618ece3b8828418608df9e99bc3a47324cfb25d263b9f

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. You get a cache export the builder refuses, a green build that pushed nothing, or a docker buildx bake whose targets nobody can find. You'll run 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. They're 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 use BuildKit's plain progress format, which is what CI logs show. In an interactive terminal you get the same steps as a collapsing view, or you can add --progress=plain.

Dockerfile
FROM golang:1.27-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c 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 that Buildx can send builds to. Every Docker host has one called default:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.9.2
$ docker buildx ls
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS default* docker \_ default \_ default running v0.34.0 linux/arm64
$ docker buildx inspect default
Name: default Driver: docker Last Activity: 2026-10-26 08:15:19 +0000 UTC Nodes: Name: default Endpoint: default Status: running BuildKit version: v0.34.0 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. That's 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, and the rest stay within free-space limits. The docker driver has no config of its own: no buildkitd.toml and no BuildKit version choice. 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). 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 config does two things. First, builds pull Docker Hub images through mirror.gcr.io, Google's public cache of often-pulled Docker Hub images. That takes most pulls off a shared CI egress IP, and so away from Docker Hub's anonymous pull limit. Images the mirror doesn't hold still come from Docker Hub and count against the limit. So authenticated pulls or your own pull-through cache are still the full answer. Second, the builder talks plain HTTP to the lab registry on localhost:5000. dockerd allows that for localhost by default, but a separate BuildKit daemon needs it spelled out. Create the builder:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.9.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.7s done #1 creating container buildx_buildkit_lab-builder0 #1 creating container buildx_buildkit_lab-builder0 0.5s done #1 DONE 3.3s 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.34.0 linux/arm64 buildx_buildkit_lab-builder0 moby/buildkit:buildx-stable-1 Up Less than a 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-26 08:15:40 +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 when you created it. So if you edit your copy later, you'll 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.9.2
$ docker buildx build --builder lab-builder -t lab-api:1 .
#0 building with "lab-builder" instance using docker-container driver ... #5 DONE 16.4s #5 [build 1/3] FROM docker.io/library/golang:1.27-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c #5 extracting sha256:070112d392529aede516cb12dd04bbbf175fcab48c54121d76e09a5043ce92a1 0.0s done #5 extracting sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1 done #5 DONE 16.4s #6 [build 2/3] WORKDIR /src #6 DONE 0.8s #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 7.6s #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 (16 seconds here) even though the daemon already had it. This builder doesn't share the daemon's store, so it pulls through its own registry config. 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.9.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:143561756cd84a1ffdd22720a353f2fa2c2e0584067443465f8930290065cc64 #9 exporting manifest sha256:143561756cd84a1ffdd22720a353f2fa2c2e0584067443465f8930290065cc64 done #9 exporting config sha256:c9a1cb833e1b2005dba3b9a9a2ebef712ee5eabac470f730f291782ef418e271 done #9 sending tarball #9 sending tarball 0.2s done #9 DONE 0.4s #10 importing to docker #10 DONE 0.0s
$ docker image ls lab-api
IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-api:1 143561756cd8 7.73MB 2.29MB

--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. The default builder is different. There 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.9.2
$ docker run -d --name lab-registry -p 127.0.0.1:5000:5000 registry:3
244ad479da2a5092c7b7c1772a98b2937e67a180b308daee903f15c1fa3f7bc7
$ 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:143561756cd84a1ffdd22720a353f2fa2c2e0584067443465f8930290065cc64 done #9 exporting config sha256:c9a1cb833e1b2005dba3b9a9a2ebef712ee5eabac470f730f291782ef418e271 done #9 exporting attestation manifest sha256:72eef05d920a9e0c9b81e01f841a2e3ff811eda359872087369f46fb82df800e 0.0s done #9 exporting manifest list sha256:7996102c9c0f45be3cca5a31fbe50ab5880facd8dc28ce0a0c2016f5d8577569 done #9 pushing layers 0.0s done #9 pushing manifest for localhost:5000/lab-api:1@sha256:7996102c9c0f45be3cca5a31fbe50ab5880facd8dc28ce0a0c2016f5d8577569 0.0s done #9 DONE 0.1s

It's the 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 registry tag now points to the index digest. 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 you use them.

docker buildx use changes the builder that docker build and docker buildx build pick when you don't give --builder. It's 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.9.2
$ docker buildx use lab-builder docker build -q -t lab-api:1 . docker buildx use default
sha256:f6052a49fb36bea521c8472a1befac7853ba31efecb6f4a5f01e305212b70e0a

Here docker build ran on lab-builder and didn't warn about a missing output. That's because Buildx turns on --load by default when it's invoked as docker build. docker buildx build doesn't 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 yourself or set BUILDX_BUILDER, rather than relying 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.9.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 26 13:46 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-26 13:46 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:c9a1cb833e1b2005dba3b9a9a2ebef712ee5eabac470f730f291782ef418e271 blobs/ blobs/sha256/ blobs/sha256/143561756cd84a1ffdd22720a353f2fa2c2e0584067443465f8930290065cc64 blobs/sha256/c9a1cb833e1b2005dba3b9a9a2ebef712ee5eabac470f730f291782ef418e271 blobs/sha256/d5d630dbbac2e717276c8472bc822515e1908ac8cb35b0e6d49dbb790ee167df index.json oci-layout { "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:143561756cd84a1ffdd22720a353f2fa2c2e0584067443465f8930290065cc64", "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. You fix that by exporting the cache to a registry and importing it on the next build. mode=max exports cache for every stage, not only the layers of the final image:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.9.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:ac58099f23cdb9ddbf0b7933e04d08f16d5e987aeb63cfacf93b2f77280c6f62 real 0m0.726s user 0m0.042s sys 0m0.043s
$ docker buildx imagetools inspect --raw localhost:5000/lab-api:buildcache | jq '{config: .config.mediaType, layers: [.layers[].size]}'
{ "config": "application/vnd.buildkit.cacheconfig.v0", "layers": [ 126, 92, 32, 2287613, 4190488, 2285928, 67719732, 246920 ] }

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.9.2
$ docker buildx prune --builder lab-builder -af | tail -1
Total: 482.8MB
$ 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:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c #3 [internal] load .dockerignore #4 importing cache manifest from localhost:5000/lab-api:buildcache #5 [build 1/3] FROM docker.io/library/golang:1.27-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c #6 [internal] load build context #7 [build 2/3] WORKDIR /src #7 CACHED #8 [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 #8 CACHED #9 [stage-1 1/1] COPY --from=build /out/app /app #9 CACHED real 0m2.203s user 0m0.057s sys 0m0.054s

482.8MB of local cache is gone, and the rebuild still reports every step CACHED after importing cache manifest, in 2.2 seconds. BuildKit downloaded only the cache metadata. Since 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.9.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

That's 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. Neither mode ever exports cache mounts (RUN --mount=type=cache). 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. That's the default on fresh Docker 29 installs and the lab VM's setup:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.9.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

It's 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. That's what docker/setup-buildx-action creates by default, and it works whatever store the runner's daemon uses.

Bake

A real repo builds several images with the same options: platforms, labels, cache settings, tags taken from the commit. If you write those as shell flags in each pipeline, they drift. 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 environment variables of the same name override them. _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 when you name no target. Ask Bake what it sees before you build anything:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.9.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's the first thing to run when a CI build does something odd. _common isn't 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.9.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:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c #6 resolve docker.io/library/golang:1.27-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c 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:dddc6dece6ce2c41faf781233eb1dad8e74f04f8c0f822d51b3e6085c88c8cdf Manifests: Name: localhost:5000/lab-worker:2@sha256:8315a18c1833f5f7dd2413e7ad1f6c713af4902edc2055a91d41e71d4f21d758 MediaType: application/vnd.oci.image.manifest.v1+json Platform: linux/arm64 Name: localhost:5000/lab-worker:2@sha256:17f1b5da0331bc4364af241c3d6621931d71e4913dea84701405fcc15b79a41f MediaType: application/vnd.oci.image.manifest.v1+json Platform: unknown/unknown Annotations: vnd.docker.reference.digest: sha256:8315a18c1833f5f7dd2413e7ad1f6c713af4902edc2055a91d41e71d4f21d758 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 and BuildKit builds without it. The summary at the end repeats the failed import without failing the build. The api import worked from the cache pushed earlier. The registry now has both repos, and lab-worker:2 is an index with its attestation, like any pushed build.

In CI you can change a definition without editing the file. Use --set target.key=value (with * for every target), and set variables through the environment:

ubuntu@secopslog-docker:~/lab/buildx · Docker 29.9.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. 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 you get every output and cache backend above. 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.9.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:f6052a49fb36bea521c8472a1befac7853ba31efecb6f4a5f01e305212b70e0a

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, and reset-lab.sh removes any lab- builders you forget.

Your turn

Rerun the bake-set step with TAG=4 and a different --set key, for example *.platform=linux/arm64 alone, and predict what --print will show in platforms and tags before you look. Compare with the output above, where one comma-joined string came back. Then run docker buildx bake --list=targets again and check that _common still doesn't appear as a target.

Takeaway

Always name where a build goes: --builder or BUILDX_BUILDER for the builder, and --load, --push or -o for the result. Without that, docker buildx build on lab-builder left the image only as cache, and a stray docker buildx use quietly moved plain docker build to another builder. For CI caches, mode=max cost 74MB against 2.3MB for mode=min here, but it's what let a pruned builder rebuild every step CACHED in 2.2 seconds.

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.

Related