Skip to main content
BlogDevOps

Docker Buildx Bake: Declarative Multi-Platform Image Builds

How I replaced a wall of copy-pasted docker build commands with one declarative HCL bake file — matrix targets, scoped caching, and a GitHub Actions workflow that stays identical to my local build.

Matthews Wong

Software Engineer

July 20269 min read
Docker Buildx Bake: Declarative Multi-Platform Image Builds

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.

The anatomy of a bake HCL file

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/matthews-wong/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: one definition, many variants

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.

Caching that actually gets reused

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.

Wiring bake into GitHub Actions

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.

When bake is worth it, and when it is not

SituationReach for bake?
One image, one platform, one tagNo — plain docker build is simpler
Several images sharing a base configYes — inherits removes the duplication
Same image across many platforms or versionsYes — matrix targets are built for this
Local build and CI must stay identicalYes — one file, both environments
Complex conditional build logic in a real languagePartly — 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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is docker buildx bake used for?

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.

How do matrix targets work in a bake file?

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.

How do I cache bake builds in GitHub Actions?

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.

Do I still need actions/checkout with bake-action v6 and later?

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.

When should I not use docker bake?

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.