Mastering multi-platform builds, testing, and more with Docker Buildx Bake
This guide demonstrates how to simplify and automate the process of building
images, testing, and generating build artifacts using Docker Buildx Bake. By
defining build configurations in a declarative docker-bake.hcl
file, you can
eliminate manual scripts and enable efficient workflows for complex builds,
testing, and artifact generation.
Assumptions
This guide assumes that you're familiar with:
Prerequisites
- You have a recent version of Docker installed on your machine.
- You have Git installed for cloning repositories.
- You're using the containerd image store.
Introduction
This guide uses an example project to demonstrate how Docker Buildx Bake can
streamline your build and test workflows. The repository includes both a
Dockerfile and a docker-bake.hcl
file, giving you a ready-to-use setup to try
out Bake commands.
Start by cloning the example repository:
git clone https://github.com/dvdksn/bakeme.git
cd bakeme
The Bake file, docker-bake.hcl
, defines the build targets in a declarative
syntax, using targets and groups, allowing you to manage complex builds
efficiently.
Here's what the Bake file looks like out-of-the-box:
target "default" {
target = "image"
tags = [
"bakeme:latest",
]
attest = [
"type=provenance,mode=max",
"type=sbom",
]
platforms = [
"linux/amd64",
"linux/arm64",
"linux/riscv64",
]
}
The target
keyword defines a build target for Bake. The default
target
defines the target to build when no specific target is specified on the command
line. Here's a quick summary of the options for the default
target:
target
: The target build stage in the Dockerfile.tags
: Tags to assign to the image.attest
: Attestations to attach to the image.Tip
The attestations provide metadata such as build provenance, which tracks the source of the image's build, and an SBOM (Software Bill of Materials), useful for security audits and compliance.
platforms
: Platform variants to build.
To execute this build, simply run the following command in the root of the repository:
$ docker buildx bake
With Bake, you avoid long, hard-to-remember command-line incantations, simplifying build configuration management by replacing manual, error-prone scripts with a structured configuration file.
For contrast, here's what this build command would look like without Bake:
$ docker buildx build \
--target=image \
--tag=bakeme:latest \
--provenance=true \
--sbom=true \
--platform=linux/amd64,linux/arm64,linux/riscv64 \
.
Testing and linting
Bake isn't just for defining build configurations and running builds. You can also use Bake to run your tests, effectively using BuildKit as a task runner. Running your tests in containers is great for ensuring reproducible results. This section shows how to add two types of tests:
- Unit testing with
go test
. - Linting for style violations with
golangci-lint
.
In Test-Driven Development (TDD) fashion, start by adding a new test
target
to the Bake file:
target "test" {
target = "test"
output = ["type=cacheonly"]
}
Tip
Using
type=cacheonly
ensures that the build output is effectively discarded; the layers are saved to BuildKit's cache, but Buildx will not attempt to load the result to the Docker Engine's image store.For test runs, you don't need to export the build output — only the test execution matters.
To execute this Bake target, run docker buildx bake test
. At this time,
you'll receive an error indicating that the test
stage does not exist in the
Dockerfile.
$ docker buildx bake test
[+] Building 1.2s (6/6) FINISHED
=> [internal] load local bake definitions
...
ERROR: failed to solve: target stage "test" could not be found
To satisfy this target, add the corresponding Dockerfile target. The test
stage here is based on the same base stage as the build stage.
FROM base AS test
RUN --mount=target=. \
--mount=type=cache,target=/go/pkg/mod \
go test .
Tip
The
--mount=type=cache
directive caches Go modules between builds, improving build performance by avoiding the need to re-download dependencies. This shared cache ensures that the same dependency set is available across build, test, and other stages.
Now, running the test
target with Bake will evaluate the unit tests for this
project. If you want to verify that it works, you can make an arbitrary change
to main_test.go
to cause the test to fail.
Next, to enable linting, add another target to the Bake file, named lint
:
target "lint" {
target = "lint"
output = ["type=cacheonly"]
}
And in the Dockerfile, add the build stage. This stage will use the official
golangci-lint
image on Docker Hub.
Tip
Because this stage relies on executing an external dependency, it's generally a good idea to define the version you want to use as a build argument. This lets you more easily manage version upgrades in the future by collocating dependency versions to the beginning of the Dockerfile.
ARG GO_VERSION="1.23"
ARG GOLANGCI_LINT_VERSION="1.61"
#...
FROM golangci/golangci-lint:v${GOLANGCI_LINT_VERSION}-alpine AS lint
RUN --mount=target=.,rw \
golangci-lint run
Lastly, to enable running both tests simultaneously, you can use the groups
construct in the Bake file. A group can specify multiple targets to run with a
single invocation.
group "validate" {
targets = ["test", "lint"]
}
Now, running both tests is as simple as:
$ docker buildx bake validate
Building variants
Sometimes you need to build more than one version of a program. The following example uses Bake to build separate "release" and "debug" variants of the program, using matrices. Using matrices lets you run parallel builds with different configurations, saving time and ensuring consistency.
A matrix expands a single build into multiple builds, each representing a unique combination of matrix parameters. This means you can orchestrate Bake into building both the production and development build of your program in parallel, with minimal configuration changes.
The example project for this guide is set up to use a build-time option to conditionally enable debug logging and tracing capabilities.
- If you compile the program with
go build -tags="debug"
, the additional logging and tracing capabilities are enabled (development mode). - If you build without the
debug
tag, the program is compiled with a default logger (production mode).
Update the Bake file by adding a matrix attribute which defines the variable combinations to build:
target "default" {
+ matrix = {
+ mode = ["release", "debug"]
+ }
+ name = "image-${mode}"
target = "image"
The matrix
attribute defines the variants to build ("release" and "debug").
The name
attribute defines how the matrix gets expanded into multiple
distinct build targets. In this case, the matrix attribute expands the build
into two workflows: image-release
and image-debug
, each using different
configuration parameters.
Next, define a build argument named BUILD_TAGS
which takes the value of the
matrix variable.
target = "image"
+ args = {
+ BUILD_TAGS = mode
+ }
tags = [
You'll also want to change how the image tags are assigned to these builds.
Currently, both matrix paths would generate the same image tag names, and
overwrite each other. Update the tags
attribute use a conditional operator to
set the tag depending on the matrix variable value.
tags = [
- "bakeme:latest",
+ mode == "release" ? "bakeme:latest" : "bakeme:dev"
]
- If
mode
isrelease
, the tag name isbakeme:latest
- If
mode
isdebug
, the tag name isbakeme:dev
Finally, update the Dockerfile to consume the BUILD_TAGS
argument during the
compilation stage. When the -tags="${BUILD_TAGS}"
option evaluates to
-tags="debug"
, the compiler uses the configureLogging
function in the
debug.go
file.
# build compiles the program
FROM base AS build
-ARG TARGETOS TARGETARCH
+ARG TARGETOS TARGETARCH BUILD_TAGS
ENV GOOS=$TARGETOS
ENV GOARCH=$TARGETARCH
RUN --mount=target=. \
--mount=type=cache,target=/go/pkg/mod \
- go build -o "/usr/bin/bakeme" .
+ go build -tags="${BUILD_TAGS}" -o "/usr/bin/bakeme" .
That's all. With these changes, your docker buildx bake
command now builds
two multi-platform image variants. You can introspect the canonical build
configuration that Bake generates using the docker buildx bake --print
command. Running this command shows that Bake will run a default
group with
two targets with different build arguments and image tags.
{
"group": {
"default": {
"targets": ["image-release", "image-debug"]
}
},
"target": {
"image-debug": {
"attest": ["type=provenance,mode=max", "type=sbom"],
"context": ".",
"dockerfile": "Dockerfile",
"args": {
"BUILD_TAGS": "debug"
},
"tags": ["bakeme:dev"],
"target": "image",
"platforms": ["linux/amd64", "linux/arm64", "linux/riscv64"]
},
"image-release": {
"attest": ["type=provenance,mode=max", "type=sbom"],
"context": ".",
"dockerfile": "Dockerfile",
"args": {
"BUILD_TAGS": "release"
},
"tags": ["bakeme:latest"],
"target": "image",
"platforms": ["linux/amd64", "linux/arm64", "linux/riscv64"]
}
}
}
Factoring in all of the platform variants as well, this means that the build configuration generates 6 different images.
$ docker buildx bake
$ docker image ls --tree
IMAGE ID DISK USAGE CONTENT SIZE USED
bakeme:dev f7cb5c08beac 49.3MB 28.9MB
├─ linux/riscv64 0eae8ba0367a 9.18MB 9.18MB
├─ linux/arm64 56561051c49a 30MB 9.89MB
└─ linux/amd64 e8ca65079c1f 9.8MB 9.8MB
bakeme:latest 20065d2c4d22 44.4MB 25.9MB
├─ linux/riscv64 7cc82872695f 8.21MB 8.21MB
├─ linux/arm64 e42220c2b7a3 27.1MB 8.93MB
└─ linux/amd64 af5b2dd64fde 8.78MB 8.78MB
Exporting build artifacts
Exporting build artifacts like binaries can be useful for deploying to environments without Docker or Kubernetes. For example, if your programs are meant to be run on a user's local machine.
Tip
The techniques discussed in this section can be applied not only to build output like binaries, but to any type of artifacts, such as test reports.
With programming languages like Go and Rust where the compiled binaries are usually portable, creating alternate build targets for exporting only the binary is trivial. All you need to do is add an empty stage in the Dockerfile containing nothing but the binary that you want to export.
First, let's add a quick way to build a binary for your local platform and
export it to ./build/local
on the local filesystem.
In the docker-bake.hcl
file, create a new bin
target. In this stage, set
the output
attribute to a local filesystem path. Buildx automatically detects
that the output looks like a filepath, and exports the results to the specified
path using the
local exporter.
target "bin" {
target = "bin"
output = ["build/bin"]
platforms = ["local"]
}
Notice that this stage specifies a local
platform. By default, if platforms
is unspecified, builds target the OS and architecture of the BuildKit host. If
you're using Docker Desktop, this often means builds target linux/amd64
or
linux/arm64
, even if your local machine is macOS or Windows, because Docker
runs in a Linux VM. Using the local
platform forces the target platform to
match your local environment.
Next, add the bin
stage to the Dockerfile which copies the compiled binary
from the build stage.
FROM scratch AS bin
COPY --from=build "/usr/bin/bakeme" /
Now you can export your local platform version of the binary with docker buildx bake bin
. For example, on macOS, this build target generates an
executable in the
Mach-O format — the
standard executable format for macOS.
$ docker buildx bake bin
$ file ./build/bin/bakeme
./build/bin/bakeme: Mach-O 64-bit executable arm64
Next, let's add a target to build all of the platform variants of the program.
To do this, you can
inherit the bin
target that you just created, and extend it by adding the desired platforms.
target "bin-cross" {
inherits = ["bin"]
platforms = [
"linux/amd64",
"linux/arm64",
"linux/riscv64",
]
}
Now, building the bin-cross
target creates binaries for all platforms.
Subdirectories are automatically created for each variant.
$ docker buildx bake bin-cross
$ tree build/
build/
└── bin
├── bakeme
├── linux_amd64
│ └── bakeme
├── linux_arm64
│ └── bakeme
└── linux_riscv64
└── bakeme
5 directories, 4 files
To also generate "release" and "debug" variants, you can use a matrix just like you did with the default target. When using a matrix, you also need to differentiate the output directory based on the matrix value, otherwise the binary gets written to the same location for each matrix run.
target "bin-all" {
inherits = ["bin-cross"]
matrix = {
mode = ["release", "debug"]
}
name = "bin-${mode}"
args = {
BUILD_TAGS = mode
}
output = ["build/bin/${mode}"]
}
$ rm -r ./build/
$ docker buildx bake bin-all
$ tree build/
build/
└── bin
├── debug
│ ├── linux_amd64
│ │ └── bakeme
│ ├── linux_arm64
│ │ └── bakeme
│ └── linux_riscv64
│ └── bakeme
└── release
├── linux_amd64
│ └── bakeme
├── linux_arm64
│ └── bakeme
└── linux_riscv64
└── bakeme
10 directories, 6 files
Conclusion
Docker Buildx Bake streamlines complex build workflows, enabling efficient multi-platform builds, testing, and artifact export. By integrating Buildx Bake into your projects, you can simplify your Docker builds, make your build configuration portable, and wrangle complex configurations more easily.
Experiment with different configurations and extend your Bake files to suit your project's needs. You might consider integrating Bake into your CI/CD pipelines to automate builds, testing, and artifact deployment. The flexibility and power of Buildx Bake can significantly improve your development and deployment processes.
Further reading
For more information about how to use Bake, check out these resources: