Tutorial
Docker Bake: A Practical Guide to Declarative, Parallel Docker Builds
Replace scattered docker build commands with one Bake file. A practical 2026 guide to targets, args, platforms, and CI integration.
You’ve got a docker build command for the API, another for the worker, another for the frontend, and a fourth for that internal admin tool nobody remembers deploying last week. Each has its own build args, its own platform flags, its own tag scheme. Coordinating all of them across your local dev setup, your CI pipeline, and every engineer’s machine creates friction: every time someone adds a service, they manually add another build step, and subtle differences creep in between environments. This is an updated walkthrough of Docker Bake reflecting how it’s used today, from first target to CI.
Docker Bake fixes this by letting you describe all of those builds — targets, arguments, platforms, output types — in a single configuration file, then run every build (or a chosen subset) with one command, in parallel, using shared build context and cache.
Multi-Platform Build Matrices
Bake gives you three distinct tools for avoiding repetitive target definitions, and it’s worth being precise about which one does what: platforms builds a single multi-arch image from one target, inherits lets one target reuse another’s configuration, and matrix takes a single target definition and fans it out into several concrete builds. They solve different problems, and mixing them up leads to configs that don’t do what you expect.
platforms: one target, one multi-arch image
If you just need a target to build for more than one CPU architecture, set platforms directly on that target:
target "api" {
dockerfile = "Dockerfile"
platforms = ["linux/amd64", "linux/arm64"]
tags = ["myorg/api:latest"]
}
This produces one logical image (myorg/api:latest) that supports both architectures via a manifest list. It’s still a single build target — platforms doesn’t create separate named targets, it just tells the builder which architectures to include in that one image.
inherits: reuse configuration, don’t repeat it
When several targets share most of their configuration — the same Dockerfile, the same base args — but differ in one or two fields, inherits lets you define the shared bits once:
target "base" {
dockerfile = "Dockerfile"
args = {
NODE_ENV = "production"
}
}
target "api" {
inherits = ["base"]
tags = ["myorg/api:latest"]
}
target "worker" {
inherits = ["base"]
tags = ["myorg/worker:latest"]
}
api and worker are still two separate, explicitly declared targets. inherits just saves you from copy-pasting the shared dockerfile and args block into each one — it doesn’t generate new targets on its own.
matrix: one target definition, many generated variants
matrix is different again: instead of writing out multiple targets by hand, you define one target with a matrix attribute listing the axes you want to vary, and Bake expands it into a build for every combination. Combine it with name (using the matrix variable in a template string) so each generated variant gets its own identifiable target name:
target "app" {
name = "app-${tag}"
matrix = {
tag = ["latest", "edge"]
}
dockerfile = "Dockerfile"
tags = ["myorg/app:${tag}"]
args = {
APP_TAG = tag
}
}
This single block produces two concrete targets — app-latest and app-edge — each with its own tag and its own APP_TAG build argument interpolated from the matrix value. Run docker buildx bake app-latest to build just one variant, or docker buildx bake to build every group member, including both.
Matrices aren’t limited to a single axis. You can cross multiple variables — for example, an app version and a platform — and Bake will generate a target for every combination:
target "app" {
name = "app-${version}-${platform}"
matrix = {
version = ["v1", "v2"]
platform = ["linux/amd64", "linux/arm64"]
}
dockerfile = "Dockerfile"
tags = ["myorg/app:${version}"]
platforms = [platform]
args = {
APP_VERSION = version
}
}
Here one target definition expands into four builds (app-v1-linux/amd64, app-v1-linux/arm64, app-v2-linux/amd64, app-v2-linux/arm64), each with the version and platform values interpolated straight into the tag, the platforms list, and the build args. That’s the core value of matrix: you write the shape of the variation once, and Bake handles producing every concrete build from it — no hand-maintained list of near-duplicate targets to keep in sync.
In short: reach for platforms when you want one multi-arch image, inherits when you want to stop repeating shared configuration across otherwise-distinct targets, and matrix when you want a single definition to generate a whole family of build variants.
What Is Docker Bake?
Docker Bake is a build orchestration feature built on top of BuildKit (the same engine behind docker buildx build). Instead of invoking docker build once per image, you define targets in a bakefile — a declarative description of what to build, from where, with which arguments, tags, and platforms — and then run docker buildx bake to execute some or all of those targets in a single invocation.
Think of it as the build-time equivalent of docker-compose.yml. Compose describes how services run together at runtime; a bakefile describes how images get built together at build time. In fact, Bake can read target definitions directly from a compose.yaml file’s build: sections, which makes it a natural next step if you already have a multi-service Compose setup and just never had a clean way to build all those services at once.
A bakefile can be written in three formats:
- HCL (
docker-bake.hcl) — the native, purpose-built format, with variables, functions, and composability - YAML — for teams that want Bake config to look and feel like their Compose files
- JSON — useful for generating bakefiles programmatically from scripts or other tooling
All three describe the same concepts: targets (individual image builds) and groups (named collections of targets you want to run together).
Why Complex Docker Build Setups Break Down
If you’ve only ever built one image, docker build -t myapp . is all you need. The pain shows up once your project has more than a couple of images that need to be built together, consistently, and often.
Here’s what that looks like in practice:
docker build -t registry.example.com/api:latest \
--build-arg NODE_ENV=production \
-f services/api/Dockerfile services/api
docker build -t registry.example.com/worker:latest \
--build-arg NODE_ENV=production \
-f services/worker/Dockerfile services/worker
docker buildx build --platform linux/amd64,linux/arm64 \
-t registry.example.com/frontend:latest \
-f services/frontend/Dockerfile services/frontend \
--push
Every one of these commands is imperative, sequential (unless you background them and manage wait yourself), and duplicated across your local dev scripts, your Makefile, and your CI YAML — usually with subtle drift between the three. Common failure modes:
- Argument drift. The
NODE_ENVbuild arg gets updated in CI but not in the local build script, so “works on my machine” becomes “works in my build.” - No shared context or cache logic. Each
docker buildinvocation re-sends build context and re-resolves cache independently, even when several services share a monorepo root. - Platform flags scattered everywhere. Multi-platform builds (
--platform linux/amd64,linux/arm64) get bolted onto some commands and forgotten on others. - Sequential by default. Builds run one after another unless you explicitly parallelize with background jobs — which most people don’t bother doing until CI gets slow enough to complain about.
- No single source of truth. The “how do we build this thing” knowledge lives in a Makefile, a shell script, and three Confluence pages, none of which agree.
None of these are fatal individually. Together, across a growing microservices repo, they add up to a build process nobody fully trusts.
The Declarative Alternative: One File, One Command
A bakefile collapses all of that into a single declarative definition. You describe what each target needs — its context, Dockerfile, build args, tags, platforms — once, and Bake figures out how to execute them, including running independent targets concurrently.
The mental shift is the same one you made going from standalone docker run commands to docker-compose up: you stop thinking in individual imperative steps and start thinking in terms of a build graph.
A Working Bakefile (HCL)
Here’s a bakefile for a small monorepo with an API, a worker, and a frontend:
variable "TAG" {
default = "latest"
}
variable "REGISTRY" {
default = "registry.example.com"
}
group "default" {
targets = ["api", "worker", "frontend"]
}
target "api" {
context = "./services/api"
dockerfile = "Dockerfile"
tags = ["${REGISTRY}/api:${TAG}"]
args = {
NODE_ENV = "production"
}
}
target "worker" {
context = "./services/worker"
dockerfile = "Dockerfile"
tags = ["${REGISTRY}/worker:${TAG}"]
args = {
NODE_ENV = "production"
}
}
target "frontend" {
context = "./services/frontend"
dockerfile = "Dockerfile"
tags = ["${REGISTRY}/frontend:${TAG}"]
platforms = ["linux/amd64", "linux/arm64"]
}
Run every target in the default group with one command:
docker buildx bake
Run a single target:
docker buildx bake api
Run a subset by naming multiple targets or a group:
docker buildx bake api worker
Override a variable at the command line without touching the file:
docker buildx bake --set api.tags=registry.example.com/api:v2 TAG=v2
That’s the entire workflow: one file describing every build, one command to run any combination of them.
The Same Thing in YAML
If your team already thinks in Compose syntax, the YAML form will feel immediately familiar:
target:
api:
context: ./services/api
dockerfile: Dockerfile
tags:
- registry.example.com/api:latest
args:
NODE_ENV: production
worker:
context: ./services/worker
dockerfile: Dockerfile
tags:
- registry.example.com/worker:latest
args:
NODE_ENV: production
frontend:
context: ./services/frontend
dockerfile: Dockerfile
tags:
- registry.example.com/frontend:latest
platforms:
- linux/amd64
- linux/arm64
group:
default:
targets:
- api
- worker
- frontend
The Same Thing in JSON
{
"target": {
"api": {
"context": "./services/api",
"dockerfile": "Dockerfile",
"tags": ["registry.example.com/api:latest"],
"args": { "NODE_ENV": "production" }
},
"worker": {
"context": "./services/worker",
"dockerfile": "Dockerfile",
"tags": ["registry.example.com/worker:latest"],
"args": { "NODE_ENV": "production" }
},
"frontend": {
"context": "./services/frontend",
"dockerfile": "Dockerfile",
"tags": ["registry.example.com/frontend:latest"],
"platforms": ["linux/amd64", "linux/arm64"]
}
},
"group": {
"default": {
"targets": ["api", "worker", "frontend"]
}
}
}
JSON is the least pleasant to hand-write but the easiest to generate from a script — useful if you have a service registry or monorepo tool that already knows the full list of buildable services and can emit this structure automatically.
Running Multiple Builds in Parallel
The single most practical win of Bake is that independent targets build concurrently by default when you run docker buildx bake against a group. In the example above, api, worker, and frontend don’t depend on each other, so a single docker buildx bake invocation builds all three at once rather than marching through them one at a time.
You can organize groups around how your team actually works, not just “everything”:
group "backend" {
targets = ["api", "worker"]
}
group "all" {
targets = ["api", "worker", "frontend"]
}
Then:
docker buildx bake backend # only api + worker, in parallel
docker buildx bake all # everything, in parallel
This is the piece that directly answers the “how do I orchestrate multiple/parallel builds” question: you don’t write parallelization logic yourself. You describe the targets, group them how you like, and Bake’s build graph handles concurrency for independent targets.
Multi-Platform Build Matrices
Multi-architecture images (linux/amd64 + linux/arm64, for instance) are one of the more tedious things to manage by hand because every target that needs it requires its own --platform flag, and it’s easy to forget one when a new service is added.
In a bakefile, platforms is just another attribute per target:
target "frontend" {
context = "./services/frontend"
platforms = ["linux/amd64", "linux/arm64"]
tags = ["registry.example.com/frontend:latest"]
}
You can also define a reusable base target with inherits so other targets pick up the same platform list without repeating it — configuration reuse across targets, not a separate mechanism:
target "_platforms" {
platforms = ["linux/amd64", "linux/arm64"]
}
target "api" {
inherits = ["_platforms"]
context = "./services/api"
tags = ["registry.example.com/api:latest"]
}
target "worker" {
inherits = ["_platforms"]
context = "./services/worker"
tags = ["registry.example.com/worker:latest"]
}
This composability — building shared attributes once and inheriting them across targets — is one of the more useful HCL-specific features, and it’s the difference between a bakefile that scales cleanly to a dozen services and one that becomes its own copy-paste problem.
Wiring Bake Into CI/CD
Bake’s CI value is straightforward: replace a sequence of docker build steps in your pipeline with one docker buildx bake call, driven by a checked-in bakefile so your CI build behavior matches what engineers run locally.
Docker publishes an official GitHub Action for this, commonly used alongside the docker/setup-buildx-action step to configure the BuildKit builder first. A minimal GitHub Actions job looks like this:
name: build
on:
push:
branches: [main]
jobs:
bake:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: registry.example.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/bake-action@v5
with:
push: true
set: |
*.tags=registry.example.com/*:${{ github.sha }}
The set input lets you override bakefile values from the pipeline — tagging every image with the commit SHA, for instance — without maintaining a separate CI-only bakefile. The idea is to keep a single checked-in bakefile that you use both locally with docker buildx bake and in CI, so there’s one definition of “how we build this,” used everywhere.
Where Bake Genuinely Pays Off — and Where It’s Overkill
Bake earns its keep in a few specific situations:
- Monorepos with several independently buildable services. If you’re maintaining build args, tags, and platform lists for more than a handful of images, a single bakefile beats a sprawling Makefile or shell script every time.
- Microservices architectures with shared build conventions. Composable attributes (via
inherits) let you define a base target once — common build args, common platforms, common labels — and have every service target reuse it, so a policy change (like adding anarm64target) happens in one place. - CI/CD pipelines that need parallel, consistent builds. Once a bakefile exists, CI just calls it — no drift between what a developer runs locally and what the pipeline runs.
- Multi-platform image matrices. Bake removes the need to hand-manage
--platformflags across manydocker buildinvocations.
Bake is comparative overkill in a few common cases:
- A single-image project. If you have one Dockerfile and one build target, a bakefile adds a layer of indirection for no real gain over
docker build. - Highly dynamic, one-off build scripts. If your build parameters change per invocation in ways that don’t map cleanly to reusable targets (e.g., a build tool generating ad hoc images for testing), a bakefile can feel like unnecessary ceremony compared to a shell script.
- Teams unfamiliar with BuildKit/Buildx concepts who need to ship immediately. Bake assumes comfort with Buildx; if your team hasn’t adopted Buildx yet, that’s the more fundamental first step.
Getting Started
If you want to try this on an existing project: pick your two or three most-built images, write a docker-bake.hcl with one target per image and a default group tying them together, then replace your existing build script’s docker build calls with a single docker buildx bake call. Run it once locally to confirm the tags and platforms match what you expect, then move the same file into your CI pipeline using Docker’s Bake GitHub Action (docker/bake-action).
The value compounds as you add services — each new target is a few lines in a file everyone already understands, not a new block of imperative commands somebody has to remember to keep in sync.
FAQ
What is Docker Bake, in one sentence? Docker Bake is a BuildKit-based orchestration tool that lets you define multiple Docker image builds — their contexts, arguments, tags, and platforms — declaratively in a single file, then execute any combination of them with one command.
Does Bake replace docker build?
No. Bake runs on the same BuildKit engine as docker buildx build; it’s a layer on top that lets you describe and coordinate multiple builds instead of invoking docker build repeatedly by hand.
Can I use my existing Compose file with Bake?
Bake can read target definitions from a Compose file’s build: sections, which is a convenient path if you already have multi-service Compose configuration and want to build those services together without duplicating the definitions.
HCL, YAML, or JSON — which should I pick? HCL is the native format and supports the most composability features (variables, functions, inheritance). YAML is a good fit if your team already thinks in Compose syntax. JSON is best when you’re generating the bakefile programmatically rather than hand-writing it.
Do targets run in parallel automatically?
Yes — when you run docker buildx bake against a group of independent targets, Bake builds them concurrently rather than sequentially, without you writing any parallelization logic yourself.
Is Bake worth adopting for a single Dockerfile project?
Generally not. The declarative structure pays off once you’re coordinating multiple images, shared build arguments, or multi-platform matrices — for one image, plain docker build is simpler.