Docker Bake
Docker Bake is the Buildx command that reads build targets from an HCL, JSON, or Compose file, so several images build from one declarative definition.
Docker Bake is a Buildx feature that reads build targets from an HCL or JSON file, so several images build from one declarative definition instead of one command line per image. A single docker buildx bake invocation resolves that file, expands the targets it was asked for, and sends all of them to the same builder.
Bake moves the arguments of a build out of the shell and into version control. The interesting consequences follow from that move: targets can inherit shared fields, a group name can stand for a set of images, and the same definition runs identically on a laptop and inside a GitHub Actions job.
Definition
Bake is a command inside Buildx, invoked as docker buildx bake. Where docker buildx build describes one image through flags, bake describes any number of images through a file, and each described image is called a target.
What the file contains
A bake file is written in HCL or JSON, and a Compose file counts as a bake definition too, so an existing compose.yaml with build sections can be built with bake without a rewrite. Bake looks for the conventional names in the working directory when no file is named: docker-bake.hcl, docker-bake.json, the matching .override variants, and Compose names such as compose.yaml and docker-compose.yml. The -f flag names a file explicitly, and repeating -f merges several files into one definition.
Four block types make up the definition.
| Block | What it declares |
|---|---|
target | One build: context, Dockerfile path, tags, build args, platforms, cache settings, output |
group | A named list of targets that build together |
variable | A named value with a default that the environment can override |
function | An HCL function used inside attribute expressions |
A target carries the fields a docker buildx build command would carry as flags. context and dockerfile locate the build, tags names the output, args supplies build arguments, platforms lists the architectures, and cache-from and cache-to point at cache backends.
How targets share fields
inherits is the attribute that keeps a large file short. A target listing inherits = ["common"] starts from that target's attributes and then overrides or extends them, so a registry prefix, a base image argument, and a platform list live in one place and apply to every image built from them.
Groups sit above targets. A group names a set of targets, and a group named default is what bake builds when the command is given no positional argument. Passing a name on the command line builds that target or group instead, so one file can serve bake app, bake tools, and bake for everything.
How one invocation differs from several
The mechanical difference between bake and a sequence of build commands shows up in four places.
| Question | Repeated build commands | One bake invocation |
|---|---|---|
| Where tags and build args live | On the command line, once per image | In the file, as target attributes |
| Shared fields | Copied into each command | Declared once and inherited |
| Which builder runs the work | Whichever builder each command selects | One builder for every target in the run |
| Shared base stage | Resolved separately per command | Resolved once, reused from the builder's cache |
That last row is the reason bake matters for a repository holding several Dockerfiles. Targets built in one invocation go to the same builder, so a base stage two images share is built once and read from cache for the second target rather than rebuilt.
Two flags are worth knowing before the first run. --print resolves the file and prints the result as JSON without building, which turns a question about variable expansion into a readable answer. --set overrides attributes at the command line with a target pattern, as in --set *.platform=linux/amd64 or --set api.tags=example/api:test.
Example
A repository with an api service and a worker service can describe both images in one file. The shared fields sit in a common target that neither group builds directly:
variable "REGISTRY" {
default = "ghcr.io/example/monorepo"
}
variable "TAG" {
default = "dev"
}
group "default" {
targets = ["api", "worker"]
}
target "common" {
context = "."
args = {
NODE_VERSION = "22"
}
platforms = ["linux/amd64", "linux/arm64"]
}
target "api" {
inherits = ["common"]
dockerfile = "services/api/Dockerfile"
tags = ["${REGISTRY}/api:${TAG}"]
}
target "worker" {
inherits = ["common"]
dockerfile = "services/worker/Dockerfile"
tags = ["${REGISTRY}/worker:${TAG}"]
}docker buildx bake --print expands the variables and the inheritance and shows what will actually be built:
{
"group": {
"default": { "targets": ["api", "worker"] }
},
"target": {
"api": {
"context": ".",
"dockerfile": "services/api/Dockerfile",
"args": { "NODE_VERSION": "22" },
"tags": ["ghcr.io/example/monorepo/api:dev"],
"platforms": ["linux/amd64", "linux/arm64"]
},
"worker": {
"context": ".",
"dockerfile": "services/worker/Dockerfile",
"args": { "NODE_VERSION": "22" },
"tags": ["ghcr.io/example/monorepo/worker:dev"],
"platforms": ["linux/amd64", "linux/arm64"]
}
}
}The workflow that builds both images is one step. Docker publishes docker/bake-action for this, and the TAG variable is supplied through the environment rather than through a flag, because a variable block reads the environment first:
name: images
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/bake-action@v6
env:
TAG: ${{ github.sha }}
with:
files: ./docker-bake.hcl
targets: default
push: trueThat job pushes api and worker for both architectures, each tagged with the commit SHA. Building one image on demand needs no second workflow, only a different target name:
- uses: docker/bake-action@v6
with:
files: ./docker-bake.hcl
targets: api
push: true
set: |
api.platform=linux/amd64The same two commands run locally against the same file. docker buildx bake builds the default group, and docker buildx bake api builds one target, which is why the file rather than the workflow is the source of truth for how an image is built.
Related Terms
- How to use Docker Bake in a GitHub Actions workflow: the bake file and workflow step written out end to end, including when bake replaces a matrix of separate build jobs.
- Building several images from one monorepo: change detection that selects which targets a commit builds, plus the cost model behind building the affected set.
- Docker Buildx, the plugin that provides the bake command: builder instances, drivers, and the flags bake writes into each target.
- WarpBuild Docker builder documentation: builder profiles and the action inputs that point a bake run at a builder outside the job.
- WarpBuild caching documentation: cache action inputs, key matching, and entry expiry.
- WarpBuild pricing: per minute rates by runner type and by builder size.
FAQ
Which file does docker buildx bake read?
By default bake looks in the working directory for docker-bake.hcl, docker-bake.json, their .override variants, and Compose files such as compose.yaml or docker-compose.yml, since a Compose file is a valid bake definition. Passing -f names the file explicitly, and repeating -f merges several files into one definition.
How is bake different from running docker build twice?
Two docker build commands carry their context, Dockerfile, tags, build args, and cache flags on the command line, and each one is a separate invocation. Bake keeps those fields in a file as named targets, and one invocation resolves the file, expands the requested targets or group, and sends them to the same builder together.
How do I override a bake value without editing the file?
A variable block reads its value from the environment, so exporting TAG=abc123 before the command changes every expression that references it. The --set flag overrides target attributes directly with a target pattern, as in --set *.platform=linux/amd64, and --print renders the resolved definition as JSON without building anything.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.