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:

docker-bake.hcl
 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.

docker-bake.hcl
   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.

docker-bake.hcl
   tags = [
-    "bakeme:latest",
+    mode == "release" ? "bakeme:latest" : "bakeme:dev"
   ]
  • If mode is release, the tag name is bakeme:latest
  • If mode is debug, the tag name is bakeme: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.

Dockerfile
 # 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: