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.
Table of Contents
- 1. The problem with many individual docker build calls
- 2. The basic structure of a docker-bake.hcl file
- 3. How BuildKit implements parallel execution
- 4. Groups and inheritance with target inherits
- 5. Variables and matrix builds for multiple platforms
- 6. Named contexts: dependencies between targets
- 7. JSON as an alternative to HCL
- 8. Integration into CI/CD pipelines
- 9. Practical tips for migrating from shell scripts
- 10. Summary
- 11. FAQ
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.