Docker Buildx Bake: Declarative Multi-Platform Image Builds

Photo by John Fielding from Norwich, UK via Wikimedia Commons (CC BY 2.0)
docker buildx bake builds container images from a declarative HCL, JSON, or Compose file instead of long docker build commands. You define named targets, groups, and variables once, then build them all in parallel with a single command. The same file runs identically on your local machine and in CI, which stops build logic from drifting between the two.
The matrix attribute is a map of parameter names to lists of values, and bake builds every combination as a separate target. A required name field controls how each generated target is named using variable interpolation like a version or service name. Two axes of two values produce four targets, all built in parallel from one definition.
Set cache-from and cache-to on your targets using the GitHub Actions backend, type=gha, because hosted runners are ephemeral. Give each matrix variant its own cache scope so variants do not overwrite each other. Use mode=max to cache intermediate layers, but watch the 10 GB per-repository cache limit, which evicts least-recently-used entries.
For a pure build you can skip it. Since v6 the docker/bake-action builds from the Git context by default, so BuildKit checks out the repository itself. You still need actions/checkout if a step before the build mutates files, because Git-context builds ignore local file changes made earlier in the job.
For a single image, single platform, single tag, plain docker build is simpler and has less to learn. Bake pays off when several images share a base config, when you build the same image across many platforms or versions, or when local builds and CI must stay byte-for-byte identical. Very complex conditional logic can still be clearer in a real scripting language.

Photo by John Fielding from Norwich, UK via Wikimedia Commons (CC BY 2.0)
Key Takeaway
docker buildx bake reads a declarative HCL file that describes every image, platform, tag, and cache setting as named targets. One command builds them all in parallel, matrix targets fan a single definition into many variants, and the same file drives local builds and CI unchanged. It replaces long, drifting docker build scripts.
For a long time my container builds lived in a shell script: a wall of docker buildx build commands, each with its own --platform, --tag, --cache-from, and --build-arg flags copied and pasted with tiny differences. Every new image meant another near-identical block, and the CI YAML slowly diverged from what I ran on my laptop. docker buildx bake fixes this by moving the whole build matrix into a declarative HCL file that both my machine and GitHub Actions read the same way.
Bake is not a separate tool you install; it ships inside Buildx as the bake subcommand. You point it at a definition file, name the targets you want, and BuildKit builds them concurrently, sharing cache and layers wherever it can. I run the same file on a VPS with Docker and in CI, and the only difference is a couple of overrides passed on the command line.
A bake file has three building blocks: variables that parameterise the config, targets that describe one build each, and groups that bundle targets so a single command builds several at once. The default file name is docker-bake.hcl, and bake picks it up automatically. The single most useful feature is inherits: define shared settings once in a base target, then extend it, so a new image is a few lines instead of a full copy.
# docker-bake.hcl
variable "REGISTRY" {
default = "ghcr.io/matthewswongofficial"
}
variable "TAG" {
default = "latest"
}
# Shared base — every real target inherits from this.
target "_common" {
context = "."
dockerfile = "Dockerfile"
platforms = ["linux/amd64", "linux/arm64"]
labels = {
"org.opencontainers.image.source" = "https://github.com/MatthewsWongOfficial/app"
}
}
target "api" {
inherits = ["_common"]
target = "api"
tags = ["${REGISTRY}/api:${TAG}"]
}
target "worker" {
inherits = ["_common"]
target = "worker"
tags = ["${REGISTRY}/worker:${TAG}"]
}
# A group ties targets together so one command builds both.
group "default" {
targets = ["api", "worker"]
}With that file in place, docker buildx bake with no arguments builds the default group, which builds both api and worker for amd64 and arm64 in parallel. Naming a target builds just that one. Because REGISTRY and TAG are variables, I never edit the file to ship a release; I override them at call time.
# Build everything in the default group
docker buildx bake
# Build just one target
docker buildx bake api
# Print the fully-resolved config as JSON without building
docker buildx bake --print
# Override a variable and push instead of loading locally
TAG=v1.4.0 docker buildx bake --set "*.output=type=registry"Run docker buildx bake --print before every real build. It resolves all variables, inheritance, and matrix expansion into plain JSON and builds nothing. It is the fastest way to confirm your tags and platforms are what you think they are, and it is the exact structure CI consumes when generating a build matrix.
Matrix targets are where bake earns its keep. The matrix attribute is a map of parameter names to lists of values, and bake builds every combination as a separate target. The required name field controls how each generated target is named using variable interpolation. This is how I build the same service against three Node versions, or fork one Dockerfile stage into debug and production variants, without repeating a single block.
target "test" {
name = "test-node${nodeversion}"
matrix = {
nodeversion = ["18", "20", "22"]
}
args = {
NODE_VERSION = nodeversion
}
target = "test"
}
# Multi-axis: 2 x 2 = 4 targets
target "image" {
name = "${svc}-${flavor}"
matrix = {
svc = ["api", "worker"]
flavor = ["debug", "release"]
}
target = flavor
tags = ["ghcr.io/matthewswongofficial/${svc}:${flavor}"]
}The first block expands into test-node18, test-node20, and test-node22. The second is a two-axis matrix that produces four targets covering every service and flavour combination. A single docker buildx bake run builds them all in parallel, and BuildKit deduplicates any shared layers between them automatically.
Cache is set per target with cache-from and cache-to, and the backend you choose matters enormously in CI. On my VPS the local registry backend is fine, but GitHub-hosted runners are ephemeral, so I use the GitHub Actions cache backend (type=gha). The one rule that saves the most grief: give each matrix variant its own cache scope. If two variants share a scope they clobber each other and every build looks like a cold cache.
# In the bake file — reference-type gha cache, scoped per target name.
target "_common" {
# ...
cache-from = ["type=gha,scope=build-${target}"]
cache-to = ["type=gha,scope=build-${target},mode=max"]
}
# Or override entirely from the CLI / CI:
docker buildx bake \
--set "*.cache-from=type=gha,scope=api" \
--set "*.cache-to=type=gha,scope=api,mode=max"mode=max exports cache for every intermediate layer, not just the final image, which makes rebuilds much faster but stores far more data. The GitHub Actions cache has a 10 GB per-repository limit and evicts least-recently-used entries, so a busy matrix build can quietly evict its own cache between runs. Scope narrowly and prune tags you no longer build.
The official docker/bake-action wraps the bake command for CI. As of mid-2026 it sits at v7, and since v6 it builds from the Git context by default, meaning BuildKit checks out the repo itself and you can skip actions/checkout for a pure build. The push: true input is shorthand for setting every target output to the registry. Here is the minimal workflow I use to build and push a multi-platform image on every push.
# .github/workflows/build.yml
name: build
on:
push:
branches: [main]
jobs:
bake:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3 # arm64 emulation
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/bake-action@v7
with:
files: docker-bake.hcl
targets: default
push: true
env:
TAG: ${{ github.sha }}For large matrices there is a sharper pattern: use docker buildx bake --print piped through jq in one job to emit the list of target names, feed that into a GitHub Actions matrix strategy, and run each target in its own parallel job. That gives every target an isolated runner and, crucially, its own cache scope, so a slow arm64 build never blocks the amd64 ones. I keep setup-qemu-action in the workflow because arm64 layers are cross-compiled via emulation on amd64 runners unless you attach a native arm64 builder.
| Situation | Reach for bake? |
|---|---|
| One image, one platform, one tag | No — plain docker build is simpler |
| Several images sharing a base config | Yes — inherits removes the duplication |
| Same image across many platforms or versions | Yes — matrix targets are built for this |
| Local build and CI must stay identical | Yes — one file, both environments |
| Complex conditional build logic in a real language | Partly — HCL functions help, but a script may fit better |
My rule of thumb: the moment I catch myself copy-pasting a docker build command with a small tweak, I move it into a bake target. The declarative file becomes the single source of truth, the --print output documents itself, and CI stops being a place where build logic silently drifts from what I run by hand. That alone has caught more broken releases than any test I could have written around the shell script it replaced.