Daniel Meier

Infrastructure, Linux, automation, and operational engineering

Why Docker Buildx Bake Is More Interesting Than It Looks

If you only ever build one image with one Dockerfile, Docker Bake can look a bit underwhelming.

You move a docker build command into a docker-bake.hcl file, type docker buildx bake, and at first glance it feels like you just created one more file for no obvious reason.

That is the wrong way to evaluate it.

Bake becomes interesting the moment your image builds stop being a one-liner and start becoming a system. The official Docker docs describe Bake as an abstraction over docker build that helps manage build configuration in a more consistent way for a team. That is accurate, but I think the real selling point is simpler:

Bake lets you treat image builds as configuration instead of terminal folklore.

And that is a much bigger deal than it sounds.

What Bake Actually Is

docker buildx bake is part of Buildx. Instead of encoding build behavior in a huge pile of CLI flags, environment variables, copy-pasted CI YAML, and shell scripts, you declare targets in a Bake file and execute them by name.

Docker supports Bake definitions in HCL or JSON, and it can also read Compose files and translate services into build targets. In practice, HCL is the format you probably want because it is the most expressive one and the easiest to scale.

A tiny example looks like this:

target "app" {
  context = "."
  dockerfile = "Dockerfile"
  tags = ["registry.example.com/app:latest"]
  platforms = ["linux/amd64", "linux/arm64"]
}

That alone is not revolutionary.

The value appears when the build grows beyond that.

Why Bake Files Are Cool

The short answer is that Bake gives structure to build complexity.

Without Bake, teams often end up with this kind of build logic scattered across multiple places:

  • long docker buildx build commands in CI
  • duplicated platform lists in several pipelines
  • tags assembled differently in each repository
  • cache settings defined in one job but forgotten in another
  • build arguments passed from mysterious shell wrappers
  • different people building “the same thing” in slightly different ways

At that point, the Dockerfile is no longer the whole build definition. It is only one part of it.

Bake gives you a central contract for the rest.

1. It makes builds reviewable

This is probably the biggest benefit.

When a build definition lives in a Bake file, changes to tags, platforms, outputs, build args, contexts, cache settings, and target relationships become visible in normal code review. You are no longer hunting through CI YAML and shell scripts just to answer basic questions like:

  • Are we still publishing arm64?
  • Did someone remove the registry cache?
  • Which images inherit the common labels?
  • Why is staging built differently from production?

That is a real operational improvement.

2. It separates image contents from build policy

Your Dockerfile should explain how an image is built.

Your Bake file should explain how that build is executed across environments, architectures, tags, contexts, and release flows.

That separation is healthy.

It keeps Dockerfiles from turning into policy dumps, and it keeps CI pipelines from becoming the only place where the “real” build behavior exists.

3. It removes CLI flag archaeology

Everyone has seen this:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --build-arg VERSION="$VERSION" \
  --build-arg VCS_REF="$GIT_SHA" \
  --cache-from type=registry,ref=registry.example.com/app:cache \
  --cache-to type=registry,ref=registry.example.com/app:cache,mode=max \
  --label org.opencontainers.image.revision="$GIT_SHA" \
  --push \
  -t registry.example.com/app:"$VERSION" \
  -t registry.example.com/app:latest \
  .

It works, until it has to be reused, modified, compared, or trusted.

Bake turns that into something named, declarative, and reusable. That matters because builds are rarely written once. They get maintained for years.

4. It gives you target composition

This is where Bake starts feeling genuinely nice.

You can define multiple targets, group them, inherit common settings, and build a matrix without turning your CI into a maze.

By “matrix” I mean combinations of build variants, for example:

  • the same image for linux/amd64 and linux/arm64
  • the same image in dev, test, and release variants
  • several images built against different base-image or version combinations

Instead of manually spelling out every combination in CI, Bake can describe that structure much more cleanly. That makes Bake very attractive for:

  • monorepos with several images
  • internal base-image repositories
  • multi-architecture builds
  • release pipelines with shared labels and tags
  • projects with dev, test, and release variants

If you are building more than one image, or one image in more than one mode, Bake usually starts paying for itself very quickly.

Here is a more realistic example than the tiny single-target file from above:

variable "REGISTRY" {
  default = "registry.example.com/platform"
}

variable "VERSION" {
  default = "dev"
}

target "_common" {
  labels = {
    "org.opencontainers.image.source" = "https://github.com/example-org/example-app"
  }
}

target "_release" {
  inherits = ["_common"]
  platforms = ["linux/amd64", "linux/arm64"]
  tags = ["${REGISTRY}/app:${VERSION}"]
}

target "app" {
  inherits = ["_release"]
  context = "."
  dockerfile = "Dockerfile"
}

That already shows one of Bake’s strongest patterns: define common behavior once, then inherit it from real targets.

Groups

Groups let you define named collections of targets. That is useful when one command should build more than one thing.

group "default" {
  targets = ["app", "worker"]
}

group "release" {
  targets = ["app-release", "worker-release"]
}

target "app" {
  context = "./app"
  dockerfile = "Dockerfile"
  tags = ["registry.example.com/app:dev"]
}

target "worker" {
  context = "./worker"
  dockerfile = "Dockerfile"
  tags = ["registry.example.com/worker:dev"]
}

That means:

  • docker buildx bake builds the default group
  • docker buildx bake release builds the release group
  • docker buildx bake app builds only the app target

This is much cleaner than maintaining separate CI jobs full of nearly identical docker buildx build commands.

Inheritance

Inheritance is what keeps a bigger Bake file from becoming repetitive.

target "_base" {
  args = {
    ALPINE_VERSION = "3.22"
  }
  platforms = ["linux/amd64", "linux/arm64"]
}

target "api" {
  inherits = ["_base"]
  context = "./api"
  dockerfile = "Dockerfile"
  tags = ["registry.example.com/api:latest"]
}

target "web" {
  inherits = ["_base"]
  context = "./web"
  dockerfile = "Dockerfile"
  tags = ["registry.example.com/web:latest"]
}

Now both api and web share the same architecture and base build arguments, but still have their own contexts and tags.

Matrix targets

A matrix target is where Bake starts feeling genuinely powerful.

Imagine you want to build the same image for multiple versions:

target "python" {
  name = "python-${item.version}"
  matrix = {
    item = [
      { version = "3.12" },
      { version = "3.13" }
    ]
  }
  context = "."
  dockerfile = "Dockerfile"
  args = {
    PYTHON_VERSION = item.version
  }
  tags = ["registry.example.com/python:${item.version}"]
}

That one target definition expands into multiple build targets. In practice, it is roughly equivalent to defining python-3.12 and python-3.13 separately, except you do not duplicate the whole block.

You can use the same idea for base-image variants:

target "runtime" {
  name = "runtime-${item.distro}"
  matrix = {
    item = [
      { distro = "alpine" },
      { distro = "debian" }
    ]
  }
  context = "."
  dockerfile = "Dockerfile"
  args = {
    BASE_DISTRO = item.distro
  }
  tags = ["registry.example.com/runtime:${item.distro}"]
}

Or combine it with environments:

target "app-env" {
  name = "app-${item.env}"
  matrix = {
    item = [
      { env = "dev" },
      { env = "test" },
      { env = "release" }
    ]
  }
  context = "."
  dockerfile = "Dockerfile"
  args = {
    APP_ENV = item.env
  }
  tags = ["registry.example.com/app:${item.env}"]
}

That is the kind of thing people often model with loops in shell scripts or sprawling CI YAML. Bake gives you a much better native abstraction for it.

5. It helps teams stop improvising

There is also a boring but important human factor here.

If a build is defined as docker buildx bake release, the team has a named thing. People stop reassembling the build from memory. Local builds and CI builds get closer to each other. New contributors have one place to inspect instead of three partial ones.

This reduces drift.

And in build systems, drift is where a lot of pain comes from.

When You Should Use Bake

You should seriously consider Bake when at least one of these is true:

  • you build multiple images from one repository
  • you publish multi-arch images
  • you need consistent labels, tags, args, or cache configuration
  • your CI contains long docker buildx build commands
  • different build targets share most of their settings
  • you want local and CI builds to use the same build definition
  • you maintain base images or tool images as a reusable platform asset

For platform engineering, internal developer platforms, image factories, and monorepos, I would go further:

Use Bake by default unless you have a good reason not to.

It matches the problem shape much better than giant CLI commands do.

When You Probably Do Not Need It

Bake is not mandatory for every container build on earth.

If you have one tiny image, no multi-arch, no complicated tagging, no reusable targets, and no shared CI logic, then plain docker build is fine. There is no prize for introducing extra abstraction too early.

In other words:

  • one image
  • one tag
  • one architecture
  • one straightforward build path

That does not need a framework.

Bake becomes valuable when complexity is persistent, not when complexity is imaginary.

A Good Mental Model

I think the cleanest way to think about it is this:

  • Dockerfile defines how to build an image
  • docker-bake.hcl defines how your organization uses that build

That distinction sounds subtle, but it scales extremely well.

A Dockerfile is about instructions inside the build. A Bake file is about orchestration around the build.

Once you see it that way, a lot of messy CI setups start looking like misplaced Bake logic.

Cool Stuff: Remote Bake Definitions

One of the more interesting features in the Docker docs is remote Bake file definitions.

This means Bake can fetch its definition directly from a remote Git repository or HTTPS URL.

That is not just a neat demo feature. It opens up a few genuinely useful patterns.

The basic idea looks like this:

docker buildx bake "https://github.com/example-org/example-images.git"

In that case, Bake fetches the remote repository, looks for a Bake definition there, and executes the default group or the target you ask for.

You can also pin the remote definition to a specific branch, tag, or commit-ish:

docker buildx bake "https://github.com/example-org/example-images.git#main"
docker buildx bake "https://github.com/example-org/example-images.git#v1.4.2"
docker buildx bake "https://github.com/example-org/example-images.git#8f3c2d1"

That detail matters.

If you are consuming shared build logic from somewhere else, pinning it to an explicit tag or commit makes the build behavior much more predictable. Pulling a floating branch can be fine for experiments, but for CI or release pipelines I would strongly prefer something immutable.

Another very useful pattern is inspecting the resolved configuration before actually building:

docker buildx bake "https://github.com/example-org/example-images.git#v1.4.2" --print

--print is underrated. It lets you see what Bake resolved the target to after inheritance, defaults, overrides, and remote loading. That is great for debugging and great for code review.

If the remote repository contains more than one Bake file, you can explicitly choose one:

docker buildx bake -f docker-bake.hcl "https://github.com/example-org/example-images.git#main"
docker buildx bake -f release.hcl "https://github.com/example-org/example-images.git#main"

That makes remote definitions more practical for real repositories, where you may have separate files for development, release, or CI-specific cases.

Centralized build logic

You can keep a shared Bake definition in a dedicated repository and consume it from many places. That is attractive when you want a common build model for a fleet of projects without copy-pasting the same file everywhere.

For example, a platform team could publish a standard Bake definition for:

  • OCI labels
  • cache configuration
  • output conventions
  • default targets
  • release tagging behavior

Then product repositories consume that shared logic instead of redefining it badly fifteen times.

Mixing remote logic with local context

The really nice detail is Docker’s cwd:// support. The docs show that a remote Bake definition can still consume local files or local override files relative to where you run the command.

That means you can combine:

  • centralized build policy from a remote Bake file
  • repository-local source code or local overrides

This is much more flexible than “remote file” initially sounds.

It means remote definitions are not only for fully centralized builds. They are also useful for layered builds where policy lives in one place and project-specific details live in another.

For example, imagine a platform team publishes a standard remote Bake file that defines cache behavior, labels, and release targets, but you still want to build the source code in your current repository:

target "app" {
  context = "cwd://"
  dockerfile = "Dockerfile"
  tags = ["registry.example.com/my-app:latest"]
}

Then you could run something like:

docker buildx bake "https://github.com/example-org/platform-bake.git#main" app

In that model, the build policy comes from the remote repository, but the actual source context comes from the directory where you run the command. That is a very clean split.

Combining remote and local files

The docs also show that you can specify multiple Bake files with -f, including a mix of remote files and local files using cwd://.

That is cool because it enables an override model:

  • shared base definition from a central repository
  • local metadata or per-project adjustments in the current repository

This is exactly the kind of pattern that tends to age well in larger organizations.

A simple example would look like this:

docker buildx bake \
  -f docker-bake.hcl \
  -f cwd://local-overrides.hcl \
  "https://github.com/example-org/platform-bake.git#v1.4.2" \
  --print

In plain English, that means:

  • load the main Bake file from the remote repository
  • also load a local override file from the current working directory
  • merge the result
  • print the fully resolved configuration

That is a very nice way to keep global defaults centralized while still allowing repository-level customization.

Your local override file could be something as small as:

target "app" {
  tags = ["registry.example.com/my-app:${GIT_SHA}"]
  args = {
    BUILD_ENV = "production"
  }
}

That kind of layering is exactly where Bake starts to feel like a serious configuration system instead of just a prettier CLI wrapper.

Better GitHub Actions integration

Another practical example from the docs is using a remote definition together with generated local metadata in CI, such as tags and labels coming from docker/metadata-action.

That is a strong signal that Bake is not just a local developer convenience. It is a serious CI building block.

Why This Matters More Than It First Appears

A lot of teams still treat container builds as if they were disposable command lines.

They are not.

If your images are production artifacts, then the build definition is part of your delivery system. It deserves structure, versioning, reviewability, and reuse. Bake gives you exactly that without forcing you into some completely separate toolchain.

That is why I like it.

It is not “magic Docker syntax.” It is a way to stop encoding release-critical behavior in ad hoc shell invocations.

Closing Thoughts

If your current build command fits in one line and nobody hates it, you do not need Bake just for philosophical reasons.

But if your repository builds multiple images, targets multiple architectures, publishes to registries, reuses common settings, or has a CI pipeline that reads like a shell-script hostage situation, Bake is worth adopting.

The official introduction to Bake is a good starting point, and the remote definition documentation is especially worth reading because it hints at a much more scalable way to share and compose build logic than many teams currently use.

That is the real reason Bake files are cool:

they turn image builds from an accumulation of flags into something you can actually reason about.