Docker Bake is here!

Docker just announced that Docker Bake has reached the GA. And that is huge news! As a docker captain and containers fan in general, I've already experimented with it in the past, and in my personal view, it's amazing.
In this blog post, I'm going to start with a simple example and then offer one complex solution for very specific problem at the end.
What is Bake?
Bake is a feature of Docker Buildx that lets you define your build configuration using a declarative file, as opposed to specifying a complex CLI expression. It also lets you run multiple builds concurrently with a single invocation.
A Bake file can be written in HCL, JSON, or YAML formats, where the YAML format is an extension of a Docker Compose file.

Simple example
In this example, we are defining two different targets, using two different Dockerfiles to build both the frontend and the backend. This flow is very useful in a monorepo scenarios.
group "default" {
targets = ["frontend", "backend"]
}
target "frontend" {
context = "./frontend"
dockerfile = "frontend.Dockerfile"
args = {
NODE_VERSION = "22"
}
tags = ["myapp/frontend:latest"]
}
target "backend" {
context = "./backend"
dockerfile = "backend.Dockerfile"
args = {
GO_VERSION = "1.23"
}
tags = ["myapp/backend:latest"]
}
This file allows to build both images with a single execution of docker buildx bake
, and if you want to push images to the registry, just append the --push
in the end.
As a comparison, to achieve the same with simple docker commands:
# Usual docker commands
docker build -t myapp/frontend:latest -f frontend.Dockerfile ./frontend
docker build -t myapp/backend:latest -f backend.Dockerfile ./backend
# The buildx
docker buildx bake
It get's even better if you are building cross-platform images.
Cross-platform image
In this example, we are going to make a simple Go based web server and compile it for both ARM and AMD64 architectures.
Let's start by making basic HTTP server.
package main
import (
"fmt"
"net/http"
"runtime"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from %s/%s\n", runtime.GOOS, runtime.GOARCH)
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
main.go
And a Dockerfile
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:alpine AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
COPY . .
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o server .
FROM alpine
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]
Dockerfile
Last part is the docker-bake.hcl
group "default" {
targets = ["multi-platform"]
}
target "multi-platform" {
platforms = ["linux/amd64", "linux/arm64"]
output = ["type=image"]
}
docker-bake.hcl
Once we have all these pieces in place, a simple docker buildx bake
would make a cross-platform image which can run both on ARM64 and AMD64.
Getting artifacts out of docker build to the host
In some cases, you might want to extract build artifacts back to the host system. For example, in CI enviroments building the code, running the unit tests, or sonarqube analysis, you want to extract these results back to the host which did the build (CI/CD machine). With standard docker
commands it's quite complicated, but the docker bake
here helps a lot.
With a simple extension of previous docker-bake.hcl
by adding new target, with output
of type=local
we can get stuff back
target "bin" {
platforms = ["linux/amd64", "linux/arm64"]
output = ["type=local,dest=./build/bin"]
}
Now, if we build and check the working dir, we should have binaries available.

Another use case, quite common in CI/CD environments - build a web app, run unit tests and static analysis tools.
Performing these steps in a multi-stage Docker build is not very hard. The issue is way bigger if you need some of these reports (xml/json/etc). to be available on the host machine, so the CI/CD system can access and process them.
Spring-Boot scenario
In my work, I work a lot with SpringBoot & Atlassian Bamboo, and we all know how "flexible" bamboo is.
And docker bake
makes this task so simple. For this demo, we are going to download a basic SpringBoot app using start.spring.io create the Dockerfile & docker-bake.hcl
definition
# syntax=docker/dockerfile:1
# Base build stage
FROM maven:3.8.6-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src/ ./src/
# Main application build target
FROM build AS application
RUN mvn clean package -DskipTests
# Test execution target
FROM build AS tests
RUN mvn test
# Gather test results in scratch image
FROM scratch AS test-results
COPY --from=tests /app/target/surefire-reports/ /
# SonarQube analysis target
FROM build AS sonar
ARG SONAR_TOKEN
ARG SONAR_PROJECT_KEY
ARG SONAR_SERVER
RUN mvn sonar:sonar \
-Dsonar.projectKey=${SONAR_PROJECT_KEY} \
-Dsonar.host.url=${SONAR_SERVER} \
-Dsonar.login=${SONAR_TOKEN}
FROM scratch AS sonar-results
COPY --from=sonar /app/target/sonar/ /
As you see, we have a few targets, one is building the actual application, second one is running all the tests, and yet another is running sonar scan. I've added a few extra targets from scratch
just to have the target with only the results of the scans.
variable "SONAR_TOKEN" {}
group "default" {
# targets = ["build", "tests", "sonar"]
targets = ["build", "tests"]
}
target "build" {
dockerfile = "Dockerfile"
target = "application"
tags = ["spring-app:latest"]
}
target "tests" {
dockerfile = "Dockerfile"
target = "test-results"
output = ["type=local,dest=./test-results"]
}
target "sonar" {
dockerfile = "Dockerfile"
target = "sonar-results"
output = ["type=local,dest=./sonar-reports"]
args = {
SONAR_TOKEN = "${SONAR_TOKEN}"
SONAR_PROJECT_KEY = "my-spring-app"
SONAR_SERVER = "https://sonarqube.example.com" # http://localhost:9000
}
depends_on = ["tests"]
}
Now, this docker-bake definition allows us to use simple docker buildx bake
to perform all the actions, and bring back the results back to the host system. Isn't that cool?

Cross-compiling against shared libraries
Have you ever had to build an app againsts system libraries? Like using glibc versus musl, or simply referencing librdkafka / opencv / tesseract?
Most of the OS'es ship different versions of these libraries, and compiling a binary against them – requires a build environment on each of them, after which you need to collect all the artefacts, and ship them in single place. Imagine a scenario were you need to build a set of binaries for each major OS / release / arch - Like two latest releases of Debian, Ubuntu, Rocky, Alpine on both architectures...
One of examples - is libtesseract. Let's try to build minimal proof-of-concept app. We going to start with simple golang app.
package main
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"github.com/otiai10/gosseract/v2"
log "github.com/sirupsen/logrus"
)
func downloadFile(filepath string, url string) error {
log.Infof("Downloading: %v", url)
// Get the data
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
// Create the file
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
// Write the body to file
_, err = io.Copy(out, resp.Body)
return err
}
func parseTextFromFile(imageFileName string, tessdata string) (string, error) {
client := gosseract.NewClient()
_ = client.SetTessdataPrefix(tessdata)
_ = client.SetLanguage("eng")
_ = client.SetPageSegMode(gosseract.PageSegMode(gosseract.PSM_OSD_ONLY))
defer client.Close()
_ = client.SetImage(imageFileName)
text, err := client.Text()
if err != nil {
return "", err
}
return text, nil
}
func downloadTesseractData() {
for _, lang := range []string{"eng"} {
path := filepath.Join(".", fmt.Sprintf("%v.traineddata", lang))
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
_ = downloadFile(path, fmt.Sprintf("https://raw.githubusercontent.com/tesseract-ocr/tessdata/main/%v.traineddata", lang))
}
}
}
func main() {
downloadTesseractData()
for _, arg := range os.Args[1:] {
text, _ := parseTextFromFile(arg, ".")
log.Infof("[%s] %s", filepath.Base(arg), text)
}
}
In order to build this for different OS'es, we need a bit complicated Dockerfile.
# syntax=docker/dockerfile:1
ARG OS_FAMILY=debian
ARG OS_VERSION=bullseye
FROM ${OS_FAMILY}:${OS_VERSION} AS builder
ARG OS_FAMILY
ARG OS_VERSION
ARG PACKAGES
# OS-specific package installation
RUN if [ "${OS_FAMILY}" = "debian" ] || [ "${OS_FAMILY}" = "ubuntu" ]; then \
apt-get update && apt-get install --no-install-recommends -y ${PACKAGES} \
; \
elif [ "${OS_FAMILY}" = "alpine" ]; then \
apk add --no-cache ${PACKAGES}; \
elif [ "${OS_FAMILY}" = "rockylinux" ]; then \
yum install -y epel-release; crb enable; \
yum install -y ${PACKAGES}; \
fi
# Set Go version
ARG GO_VERSION=1.23.5
# Determine architecture
ARG TARGETARCH
RUN wget https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz && \
tar -C /usr/local -xzf go${GO_VERSION}.linux-${TARGETARCH}.tar.gz && \
rm go${GO_VERSION}.linux-${TARGETARCH}.tar.gz
# Set Go environment variables
ENV PATH="/usr/local/go/bin:${PATH}"
ENV GOPATH="/go"
ENV PATH="${GOPATH}/bin:${PATH}"
# Verify installation
RUN go version
WORKDIR /app
COPY . .
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -v -ldflags="-s -w" -o myapp .
FROM scratch AS bin
COPY --from=builder /app/myapp /
FROM ${OS_FAMILY}:${OS_VERSION} AS runtime
ARG OS_FAMILY
ARG OS_VERSION
ARG RUNTIME_PACKAGES
RUN if [ "${OS_FAMILY}" = "debian" ] || [ "${OS_FAMILY}" = "ubuntu" ]; then \
apt-get update && apt-get install --no-install-recommends -y ${RUNTIME_PACKAGES} \
; \
elif [ "${OS_FAMILY}" = "alpine" ]; then \
apk add --no-cache ${RUNTIME_PACKAGES}; \
elif [ "${OS_FAMILY}" = "rockylinux" ]; then \
yum install -y ${RUNTIME_PACKAGES}; \
fi
COPY --from=builder /app/myapp /usr/bin/mypp
In this Dockerfile, we have 3 targets - the one which does the build, one for exporting binaries to host, and one for resulting image without dev tools, but with required libs installed.
To build all these, we need more complex docker-bake.hcl
:
group "default" {
targets = ["binaries", "images"]
}
variable "flavors" {
default = [
{os_family = "ubuntu", os_version = "noble", packages = "wget ca-certificates build-essential pkg-config libtesseract-dev", runtime_packages = "libtesseract5"},
{os_family = "debian", os_version = "bookworm", packages = "wget ca-certificates build-essential pkg-config libtesseract-dev", runtime_packages = "libtesseract5"},
{os_family = "debian", os_version = "bullseye", packages = "wget ca-certificates build-essential pkg-config libtesseract-dev", runtime_packages = "libtesseract4"},
{os_family = "rockylinux", os_version = "9", packages = "wget gcc g++ pkgconfig tesseract-devel", runtime_packages = "tesseract"},
{os_family = "alpine", os_version = "latest", packages = "wget build-base pkgconf tesseract-ocr-dev", runtime_packages = "tesseract-ocr"},
]
}
target "base" {
dockerfile = "Dockerfile"
platforms = [
"linux/amd64",
"linux/arm64",
]
}
target "binaries" {
inherits = ["base"]
matrix = {
item = flavors
}
args = {
OS_FAMILY = item.os_family,
OS_VERSION = item.os_version,
PACKAGES = item.packages,
RUNTIME_PACKAGES = item.runtime_packages,
GO_VERSION = "1.23.5",
}
name = "bin-${item.os_family}-${item.os_version}"
description = "Build binary for ${item.os_family}/${item.os_version}"
target = "bin"
output = [
"type=local,dest=./artifacts/${item.os_family}-${item.os_version}",
]
}
target "images" {
inherits = ["base"]
matrix = {
item = flavors
}
args = {
OS_FAMILY = item.os_family,
OS_VERSION = item.os_version,
PACKAGES = item.packages,
RUNTIME_PACKAGES = item.runtime_packages,
GO_VERSION = "1.23.5",
}
labels = {
"org.opencontainers.image.source" = "https://github.com/xor22h/docker-bake-quickstart"
}
name = "image-${item.os_family}-${item.os_version}"
description = "Build image for ${item.os_family}/${item.os_version}"
tags = ["ghcr.io/xor22h/docker-bake-quickstart/app:${item.os_family}-${item.os_version}"]
}
Now, without default
target this definition has two primary targets:
images
to build the docker containers for each OS/Versionbinaries
to build the os specific binaries.
This allows us to:
# build both binaries & images but don't push them
docker buildx bake
# build binaries only
docker buildx bake binaries
# build & push images
docker buildx bake images --push
In conclusion, Docker Bake offers a powerful and flexible way to define and execute complex build processes, making it an invaluable tool for developers and DevOps professionals working with containerized applications.
Find more about bake in official announcement

You can find full code for examples above in the github repository.