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.

BlockWhat it declares
targetOne build: context, Dockerfile path, tags, build args, platforms, cache settings, output
groupA named list of targets that build together
variableA named value with a default that the environment can override
functionAn 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.

QuestionRepeated build commandsOne bake invocation
Where tags and build args liveOn the command line, once per imageIn the file, as target attributes
Shared fieldsCopied into each commandDeclared once and inherited
Which builder runs the workWhichever builder each command selectsOne builder for every target in the run
Shared base stageResolved separately per commandResolved 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: true

That 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/amd64

The 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.

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.