Docker Bake: Building Multiple Images Declaratively and in Parallel
AI generated
FROM
RUN
Docker · BuildKit · CI/CD
Docker Bake
building multiple images declaratively and in parallel

Anyone maintaining five, ten, or twenty images in a monorepo knows the problem: a growing list of individual docker build calls in a shell script, running one after another and requiring manual edits every time a new image is added. docker buildx bake replaces those scripts with a declarative file and builds every image in parallel.

17 min read buildx bake HCL multi-image build parallel builds monorepo

1. The problem with many individual docker build calls

In many projects, the number of Docker images grows organically over time: one image for the web server, one for the worker, one for the cron runner, one for the test environment, each with slightly different build args, tags, and target platforms. The obvious first step is a shell script with several docker build lines in a row, which quickly becomes unwieldy and, more importantly, builds every image strictly sequentially, even when the underlying hardware could easily handle several builds at once.

docker buildx bake, a permanent part of the Docker CLI since BuildKit, solves exactly this problem. Instead of many individual commands, a declarative configuration file describes every image along with its build context, build args, and tags. A single docker buildx bake call then builds all or a selected subset of these images, with BuildKit automatically parallelizing independent builds and even reusing layer caches across related images.

2. The basic structure of a docker-bake.hcl file

The default configuration file is called docker-bake.hcl and uses HCL, the Hashicorp Configuration Language, the same language used by Terraform. At its core, a bake file consists of so called targets, each one corresponding to a single docker build call with its own context, Dockerfile, build args, and tags. When docker buildx bake is run without further arguments, it automatically looks for docker-bake.hcl in the current directory and builds every target defined there by default.

Every target can define its own values for context, dockerfile, tags, args, and platforms, allowing individual settings per image without a central script needing separate branches for each one. This declarative nature makes bake files considerably easier to maintain than grown shell scripts, because new images are simply added as another target block without touching any existing logic.


// docker-bake.hcl
group "default" {
  targets = ["web", "worker", "cron"]
}

target "web" {
  context    = "./services/web"
  dockerfile = "Dockerfile"
  tags       = ["mironsoft/web:latest"]
}

target "worker" {
  context    = "./services/worker"
  dockerfile = "Dockerfile"
  tags       = ["mironsoft/worker:latest"]
}

target "cron" {
  context    = "./services/cron"
  dockerfile = "Dockerfile"
  tags       = ["mironsoft/cron:latest"]
}

3. How BuildKit implements parallel execution

When docker buildx bake is invoked without restriction, BuildKit first determines the dependency graph between the targets. Targets with no mutual dependency, for example because none of them references another one's output via contexts, are built in parallel, bounded by the available CPU cores and the configured builder. In practice, for a project with five independent images that means a considerably shorter total build time compared to five sequential docker build calls, especially on multi core CI runners.

Additionally, parallel builds share the same BuildKit cache, so identical base layers, such as a shared FROM node:20-alpine or a shared apt-get layer, are built only once and reused for every affected target, instead of being executed redundantly multiple times. This effect is amplified further when several images build on top of a shared base image or a shared build stage inside a multi-stage Dockerfile.


# Build every target in the default group in parallel
docker buildx bake

# Build only a specific target
docker buildx bake worker

# Build several specific targets
docker buildx bake web worker

# Show the planned build without actually building
docker buildx bake --print

4. Groups and inheritance with target inherits

Bake files support group blocks, which let you bundle several targets into named collections, for example a production group with all production images and a separate ci group with additional test images. When docker buildx bake production is invoked, BuildKit builds only the targets listed in that group, enabling focused partial builds without separate configuration files.

Even more powerful is inheritance via inherits: a base target can define shared settings such as platforms, common build args, or labels, and concrete image targets inherit these values while selectively overriding individual fields such as context or tags. This significantly reduces duplication, especially in projects with many structurally similar images, such as a series of microservices that should all use the same Node version and the same standard labels.


// docker-bake.hcl with groups and inheritance
group "production" {
  targets = ["web", "worker"]
}

group "ci" {
  targets = ["web", "worker", "test"]
}

target "_common" {
  args = {
    NODE_VERSION = "20"
  }
  labels = {
    "org.opencontainers.image.vendor" = "Mironsoft"
  }
}

target "web" {
  inherits   = ["_common"]
  context    = "./services/web"
  tags       = ["mironsoft/web:latest"]
}

target "worker" {
  inherits   = ["_common"]
  context    = "./services/worker"
  tags       = ["mironsoft/worker:latest"]
}

target "test" {
  inherits = ["_common"]
  context  = "./services/web"
  target   = "test-stage"
}

5. Variables and matrix builds for multiple platforms

HCL bake files support their own variable declarations with default values, which can be overridden via environment variables at build time, similar to ARG in a Dockerfile, but at the level of the entire bake file instead of a single image. This allows, for example, tagging images dynamically with the current Git commit SHA or a version number, without manually editing the bake file on every release.

For multi platform builds, Bake also supports a matrix syntax that automatically expands a single target template into several concrete targets, for example for a combination of several Node versions and several target architectures. This avoids manually duplicating nearly identical target blocks and keeps the bake file readable even with many build variants.


// docker-bake.hcl with variables and a matrix
variable "TAG" {
  default = "latest"
}

variable "REGISTRY" {
  default = "docker.io/mironsoft"
}

target "web" {
  context   = "./services/web"
  tags      = ["${REGISTRY}/web:${TAG}"]
  platforms = ["linux/amd64", "linux/arm64"]
}

target "matrix-test" {
  matrix = {
    node_version = ["18", "20", "22"]
  }
  name       = "test-node-${node_version}"
  context    = "./services/web"
  target     = "test-stage"
  args = {
    NODE_VERSION = node_version
  }
}

// Invocation with an overridden TAG:
// TAG=2.4.1 docker buildx bake web

6. Named contexts: dependencies between targets

In some projects, one image builds on the result of another image, for example when a shared base image with preinstalled dependencies should be built first and then referenced by several application images instead of being rebuilt from scratch every time. Bake supports so called named contexts for this via the contexts field within a target, which can reference another target as a build context, for example contexts = { base = "target:base-image" }.

BuildKit automatically recognizes this dependency and builds the referenced base target first, before starting the targets that depend on it, eliminating what used to be a purely manual intermediate step of a separate docker build for the base image. Inside the dependent Dockerfile, the named context is then referenced like a normal build stage name, for example via FROM base AS runtime, allowing complex dependency chains between several independent Dockerfiles to be modeled cleanly in a single bake file.


// docker-bake.hcl with a named context between two targets
target "base-image" {
  context    = "./base"
  dockerfile = "Dockerfile"
  tags       = ["mironsoft/base:latest"]
}

target "app" {
  context    = "./services/app"
  dockerfile = "Dockerfile"
  contexts = {
    base = "target:base-image"
  }
  tags = ["mironsoft/app:latest"]
}

// In the Dockerfile of ./services/app:
// FROM base AS runtime

7. JSON as an alternative to HCL

Anyone who does not want to introduce HCL, or who already generates build metadata from another system, can also write bake files as plain JSON, typically named docker-bake.json. The structure matches the HCL variant exactly with group and target objects, just in JSON syntax, which is especially useful when the file is generated programmatically by a build script or a CI pipeline instead of being maintained by hand.

A practical advantage of JSON is easy integration with existing tools: a Node.js or Python script can derive the list of images to build, for example from a package.json or a central service registry, and automatically generate a valid docker-bake.json from it before calling docker buildx bake -f docker-bake.json. For purely manually maintained configurations, HCL usually remains the more pleasant choice due to better readability and comment support.


# docker-bake.json equivalent to the HCL file, as JSON
# {
#   "group": { "default": { "targets": ["web", "worker"] } },
#   "target": {
#     "web": { "context": "./services/web", "tags": ["mironsoft/web:latest"] },
#     "worker": { "context": "./services/worker", "tags": ["mironsoft/worker:latest"] }
#   }
# }

docker buildx bake -f docker-bake.json

8. Integration into CI/CD pipelines

In CI/CD pipelines, Bake pays off particularly well because a single build step is enough to build every relevant image for a commit, instead of a matrix of several parallel jobs each consuming its own runner resources. Combined with the --push option, every built image can be published directly to a registry in the same call, without needing separate docker push commands for each individual image.

For pull request builds where only changed services should be rebuilt, target selection can be driven dynamically based on changed directories, for example by having a CI script determine affected targets via git diff and passing only those to docker buildx bake. This considerably reduces unnecessary build time in monorepos, where a single commit usually only affects a small fraction of the total number of images.


# Build every image AND push directly to the registry
docker buildx bake --push

# Build only changed targets (example logic in CI)
CHANGED=$(git diff --name-only origin/main... | grep -oP '^services/\K[a-z]+' | sort -u)
docker buildx bake $CHANGED --push

9. Practical tips for migrating from shell scripts

Migrating from a grown shell script to Bake is usually worthwhile from about three or four independent images onward, since maintaining the script becomes noticeably more effort than maintaining a declarative file beyond that size. A good first step is to translate every existing docker build command one to one into a target, without immediately introducing groups or inheritance, and only afterward gradually extract commonalities into a shared _common target.

With docker buildx bake --print, the resulting build plan can be printed as JSON before any real build runs, which is especially helpful when debugging inheritance and variable resolution, since errors in the HCL structure become visible before a build process even starts. This dry run option should be a fixed part of every change to the bake file before it gets adopted into a CI pipeline.

Aspect Multiple docker build calls docker buildx bake
Execution Sequential, one command after another Parallel, BuildKit determines the dependency graph
Configuration Spread across shell script lines Centralized in docker-bake.hcl or .json
Reuse of shared values Manual via script variables Built in through inherits and _common targets
Multi-platform/matrix builds Tedious, many individual commands needed Built in matrix syntax
CI integration with registry push Separate docker push per image One call with --push for every target

Mironsoft

Container infrastructure, CI pipelines and deployment automation

Docker setups that hold up across the team and in production?

We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.

Dockerfile Review

Systematically optimizing multi-stage builds, layer caching and image size.

Security Audit

Hardening container isolation, secrets handling and image scanning against real attack surfaces.

CI/CD Integration

Building build pipelines, registries and deployment strategies for reproducible releases.

10. Summary

Docker Bake: Key Takeaways

Declarative instead of scripted

A single docker-bake.hcl describes every image centrally instead of many shell lines.

Automatic parallelization

BuildKit builds independent targets simultaneously and shares the layer cache.

Groups and inheritance

group and inherits blocks bundle targets and significantly reduce duplication.

CI friendly

A single call with --push builds and publishes every relevant image at once.

11. FAQ: Docker Bake: Key Takeaways

1What is the main advantage of docker buildx bake over multiple docker build commands?
The main advantage is parallel execution of independent builds through BuildKit, along with a centralized, declarative configuration instead of a growing collection of individual shell commands. That shortens overall build time and significantly eases maintenance.
2Do I need to learn HCL to use docker buildx bake?
Not necessarily, bake files can also be written as plain JSON under the name docker-bake.json. For manually maintained configurations, though, HCL is usually more pleasant thanks to comment support and better readability.
3How do I build only a single target instead of every defined image?
With docker buildx bake , for example docker buildx bake worker. Only that target and any of its dependencies get built, every other target in the file remains untouched.
4What does the command docker buildx bake --print do?
It prints the resulting build plan as JSON, including resolved variables and inheritance, without actually starting a build. That is excellent for debugging complex bake files before running a real build.
5Can I push images directly to a registry with Bake?
Yes, with the --push option, for example docker buildx bake --push, every built image is published in the same run to whichever registry is configured in tags, without separate docker push commands per image.
6How does inheritance between targets work in a bake file?
Through the inherits field, a target can adopt the settings of another target, such as a shared _common target, while selectively overriding individual fields like context or tags. This significantly reduces duplication for structurally similar images.
7What is a group in a docker-bake.hcl file?
A group bundles several targets under a shared name, such as production or ci. When docker buildx bake is invoked with the group name, only the targets listed in that group are built.
8Can bake variables be overridden at build time?
Yes, through environment variables with the same name as the values defined in variable, for example TAG=2.4.1 docker buildx bake web, to override the default tag for that one call.
9What is a matrix build in docker buildx bake?
A matrix build automatically expands a single target template into several concrete targets based on combined values, for example several Node versions or target architectures, instead of manually duplicating every combination as its own target.
10Do targets built in parallel actually share the same build cache?
Yes, as long as they use identical or similar layers, such as the same base image or the same early build stage in a multi-stage Dockerfile, those layers are built only once by BuildKit and reused for every affected target.