Skip to content
Go back

Docker Bake vs Compose Build

By SumGuy 10 min read
Docker Bake vs Compose Build

You Have Ten Services. CI Has Opinions.

Four architectures. Three tag variants — latest, a git SHA, and a semver release. Ten services, each with a Dockerfile that needs to build cleanly before anything ships.

Your options: write a 200-line GitHub Actions matrix that slowly drains your sanity, or find a better tool. docker buildx bake exists for exactly this scenario, and most people have never touched it because docker compose build got there first and felt “good enough.”

It was. For one service. On one arch. On your laptop. The moment CI enters the picture, that calculus changes fast.

This post walks through both approaches on the same 4-service stack, shows you what each buys you, and tells you when to stop fighting compose and reach for bake instead.


The Stack We’re Building

Four services, two architectures (linux/amd64, linux/arm64), three image tags:

ServiceContextNotes
api./apiGo binary
worker./workerPython consumer
scheduler./schedulerNode cron
proxy./proxyCaddy config

Simple enough to understand, complex enough that you’ll feel the pain of compose-only builds before we’re done.


Option 1: docker compose build

You know this one. It’s right there in your docker-compose.yml already.

docker-compose.yml
services:
api:
build:
context: ./api
dockerfile: Dockerfile
args:
APP_VERSION: "1.4.2"
image: ghcr.io/myorg/api:latest
worker:
build:
context: ./worker
target: production
image: ghcr.io/myorg/worker:latest
scheduler:
build:
context: ./scheduler
image: ghcr.io/myorg/scheduler:latest
proxy:
build:
context: ./proxy
image: ghcr.io/myorg/proxy:latest

Run it:

Terminal window
docker compose build --parallel
docker compose push

The --parallel flag helps — BuildKit will fan out builds that don’t share layers. But here’s what you cannot do with compose:

No matrix expansion. Want linux/amd64 and linux/arm64? That’s a separate docker buildx build --platform call per service. You’re writing four shell commands, or a loop, or a GitHub Actions matrix — and now your CI file is the source of truth for your build topology instead of your compose file.

No target inheritance. If three services share the same base image and ARG pattern, you’re copy-pasting the same build: block and hoping you remember to update all four when the base image bumps.

No variables. The APP_VERSION arg above is hardcoded in the YAML. In CI you’ll do string replacement or pass --build-arg on the CLI, which means your CI YAML is now annotated with context that belongs in a build definition file.

No group builds. You can’t say “build these three services together, and that one separately before the others.” Everything runs flat.

For a dev loop where you’re running docker compose up --build while editing code, none of this matters. Compose is fine. The friction shows up the moment you’re writing a release pipeline.


Option 2: docker buildx bake

Bake is BuildKit’s first-class multi-image build orchestrator. It reads a declarative definition — HCL, JSON, or a compose file — and hands the whole thing to BuildKit in one shot. BuildKit handles the parallelism, the caching, and the layer sharing. You handle the definition.

The canonical format is HCL because it supports variables, functions, and inheritance that JSON and YAML can’t express cleanly.

Basic Bake File

docker-bake.hcl
variable "REGISTRY" {
default = "ghcr.io/myorg"
}
variable "TAG" {
default = "latest"
}
variable "PLATFORMS" {
default = ["linux/amd64", "linux/arm64"]
}
group "default" {
targets = ["api", "worker", "scheduler", "proxy"]
}
target "base" {
platforms = PLATFORMS
labels = {
"org.opencontainers.image.source" = "https://github.com/myorg/myrepo"
}
}
target "api" {
inherits = ["base"]
context = "./api"
tags = ["${REGISTRY}/api:${TAG}"]
args = {
APP_VERSION = TAG
}
}
target "worker" {
inherits = ["base"]
context = "./worker"
target = "production"
tags = ["${REGISTRY}/worker:${TAG}"]
}
target "scheduler" {
inherits = ["base"]
context = "./scheduler"
tags = ["${REGISTRY}/scheduler:${TAG}"]
}
target "proxy" {
inherits = ["base"]
context = "./proxy"
tags = ["${REGISTRY}/proxy:${TAG}"]
}

Build everything:

Terminal window
docker buildx bake --push

That’s it. Four services, two platforms, one command. BuildKit fans them all out in parallel. The base target carries the shared config — platforms, labels, whatever — and each service target inherits it. Change the base image? One edit.

Override at Call Time

Variables can be overridden from the environment:

Terminal window
TAG=1.4.2 docker buildx bake --push

Or pass them explicitly:

Terminal window
docker buildx bake --set "*.platforms=linux/amd64" --push

The *.platforms syntax applies to all targets matching the glob. Useful when you want amd64-only builds locally and multi-arch only in CI, without maintaining two bake files.

Multiple Tags Per Image

The release build that needs latest, a SHA, and a semver:

docker-bake.hcl
variable "GIT_SHA" {
default = "dev"
}
variable "VERSION" {
default = "0.0.0"
}
target "api" {
inherits = ["base"]
context = "./api"
tags = [
"${REGISTRY}/api:latest",
"${REGISTRY}/api:${GIT_SHA}",
"${REGISTRY}/api:${VERSION}",
]
}
Terminal window
GIT_SHA=$(git rev-parse --short HEAD) VERSION=1.4.2 docker buildx bake --push

Three tags, one build. BuildKit builds the image once and pushes to all three tags. With compose you’d either build three times or write a shell script that tags and pushes separately after the fact.


Bake Can Read Your Compose File

Here’s the part that makes the migration story friendlier: bake can consume your existing docker-compose.yml directly.

Terminal window
docker buildx bake -f docker-compose.yml

It reads the build: sections and constructs targets from them. You get bake’s parallelism and BuildKit scheduling without rewriting anything. It won’t give you target inheritance or HCL variables, but it’s a clean starting point for a team that lives in compose and wants multi-arch without the shell script tax.

You can also layer an override file:

Terminal window
docker buildx bake -f docker-compose.yml -f docker-bake-override.hcl --push

The override adds platforms, tags, and cache settings on top of the compose-defined contexts. Compose stays the dev config; the HCL override is the release config. Both files check in together.


CI: The Real Reason You’re Here

Here’s the same release pipeline written both ways.

Compose Build in GitHub Actions

.github/workflows/release-compose.yml
name: Release (compose)
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
service: [api, worker, scheduler, proxy]
platform: [linux/amd64, linux/arm64]
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push ${{ matrix.service }} on ${{ matrix.platform }}
run: |
docker buildx build \
--platform ${{ matrix.platform }} \
--push \
--cache-to type=gha,mode=max \
--cache-from type=gha \
-t ghcr.io/myorg/${{ matrix.service }}:${{ github.ref_name }} \
./${{ matrix.service }}

That matrix runs 8 jobs (4 services × 2 platforms). Each job is a separate BuildKit invocation with its own cache slot. You’ll also need a separate manifest merge step to combine the amd64 and arm64 digests into a single multi-arch image. That’s another 10+ lines you haven’t written yet.

Bake in GitHub Actions

.github/workflows/release-bake.yml
name: Release (bake)
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push all services
uses: docker/bake-action@v6
with:
push: true
files: docker-bake.hcl
set: |
*.cache-to=type=gha,mode=max
*.cache-from=type=gha
env:
GIT_SHA: ${{ github.sha }}
VERSION: ${{ github.ref_name }}

One job. One bake invocation. BuildKit builds all four services across both platforms in a single coordinated pass, produces proper multi-arch manifests automatically, and pushes. The manifest merge is not a separate step because bake + BuildKit handle it natively.

The job count dropped from 8 to 1. The step count dropped from ~50 to ~25. The cache is shared across all services because there’s one BuildKit context instead of eight isolated ones.


Caching: Where Bake Really Pulls Ahead

With the matrix approach, each job gets its own GHA cache entry. api on amd64 does not reuse warm layers from api on arm64. You’re paying for the same base image download multiple times per run.

With bake, BuildKit’s internal scheduler knows about all targets simultaneously. If api and worker share a python:3.13-slim base, BuildKit pulls it once and reuses it across both. The type=gha cache is written once per unique layer, not once per job.

On a warm cache, a 4-service multi-arch bake run is consistently faster than the equivalent 8-job matrix, even accounting for QEMU overhead. On a cold cache the difference is less dramatic, but the cache warms faster next time because all layers land in a single cache bucket.


When Compose Build Still Wins

Don’t tear out your compose build config tomorrow. There are cases where it’s the right call:

Dev loops. You’re in docker compose up --build, editing code, watching containers restart. Compose is orchestrating containers and rebuilding them. Bake doesn’t run containers — it’s purely a build tool. You’d still use compose to manage the running stack.

Single-service rebuilds. docker compose build api is faster to type than docker buildx bake api and everyone on the team already knows it. For a quick “rebuild this one thing and test it” workflow, compose wins on ergonomics.

Simple single-arch setups. If you’re shipping to amd64 only and your images are straightforward, compose build is not a bottleneck. Don’t add complexity you don’t need.

Teams unfamiliar with HCL. The bake HCL syntax is readable but it’s one more thing to learn. If your team is already stretched and “it ships” is the success metric, add bake when the pain of the current approach is visible.


The Verdict

Keep compose for dev loops. Use bake for releases and CI.

They’re complementary, not rivals. Your docker-compose.yml doesn’t go away — it keeps doing what it’s good at (running the stack locally, wiring up volumes and networks, making docker compose up work). The docker-bake.hcl lives next to it and owns the build topology for release pipelines.

The migration path is gradual: start with docker buildx bake -f docker-compose.yml to get bake’s parallelism with zero new config. Add an HCL override when you need variables or multi-arch. Move fully to HCL targets when you want inheritance and the compose build sections are just noise.

The 200-line GitHub Actions matrix is the thing you should be getting rid of. Bake is the tool that lets you do it.


Quick Reference

Capabilitycompose builddocker buildx bake
Multi-arch buildsCLI flags onlyNative, declarative
Target inheritanceNoYes (HCL inherits)
VariablesEnv substitution onlyFirst-class HCL vars
Multiple tags per imageNoYes
Group buildsNoYes
Reads compose filesNativeYes (-f docker-compose.yml)
Good for dev loopsYesNo (build only, no run)
Good for CI/releaseGets messy fastYes
Cache sharing across targetsPer-jobShared BuildKit context

Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Cosign Keyless: Sign Without Keys
Next Post
Heimdall vs Homepage vs Homer: Status Dashboards

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts