How Do I Use Docker Bake in GitHub Actions?
Define each image as a target in a bake file, then call the bake action once. One invocation builds every target against one builder and one layer cache.
Answer
Define each image as a target in a docker-bake.hcl file, commit it, and call the bake action once from the workflow: one invocation resolves the file, expands the targets or the group you name, and builds all of them against one builder. On WarpBuild, that means swapping docker/bake-action for Warpbuilds/bake-action and adding a profile-name input that points the run at a remote Docker builder profile, which the Docker builders documentation documents as a drop-in replacement.
The reason to reach for bake instead of a matrix of build jobs is the count of round trips. A matrix runs one GitHub Actions job per image, and every one of those jobs checks out the repository, requests its own builder, waits for that builder to report ready, and runs its own post-job steps. One bake invocation does that once for the whole set. Docker Bake, defined covers the file format itself: targets, groups, variables, and the inherits attribute that keeps shared fields in one place.
The builder that bake dispatches to is a separate resource from the runner that drives it. Docker builds on GitHub Actions is the wider pattern this page sits inside.
Detail
A bake file with two targets sharing arguments and cache settings
Both images below build from the same repository root and the same language toolchain version, so those fields live on a shared target that nothing builds directly. Docker documents the attributes and the inheritance rules in the bake file reference.
variable "REGISTRY" {
default = "ghcr.io/example/platform"
}
variable "TAG" {
default = "dev"
}
variable "CACHE_REF" {
default = "ghcr.io/example/platform/buildcache"
}
group "default" {
targets = ["gateway", "indexer"]
}
target "shared" {
context = "."
platforms = ["linux/amd64"]
args = {
GO_VERSION = "1.24"
}
cache-from = ["type=registry,ref=${CACHE_REF}"]
cache-to = ["type=registry,ref=${CACHE_REF},mode=max"]
}
target "gateway" {
inherits = ["shared"]
dockerfile = "services/gateway/Dockerfile"
tags = ["${REGISTRY}/gateway:${TAG}"]
}
target "indexer" {
inherits = ["shared"]
dockerfile = "services/indexer/Dockerfile"
tags = ["${REGISTRY}/indexer:${TAG}"]
}docker buildx bake --print renders the resolved definition as JSON without building anything, which is the fastest way to check that TAG and inherits expanded the way you expected before a workflow run proves otherwise.
The two cache attributes are worth reading closely, because they are the lines that come out once a remote builder runs these targets. The Docker builders documentation states that cache-to and cache-from are not required with WarpBuild builders: the builder caches the layers on its own disk and reuses them on later builds. Leaving a registry export in place pays to push and pull layers the builder is already holding. When part of your build set still runs on a runner's local BuildKit, keep the attributes behind a bake variable so the same file serves both paths.
The workflow step
name: images
on:
push:
branches: [main]
pull_request:
jobs:
images:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v5
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build every target
uses: Warpbuilds/bake-action@v6
env:
TAG: ${{ github.sha }}
with:
files: ./docker-bake.hcl
targets: default
push: ${{ github.event_name == 'push' }}
profile-name: "platform-builder"
timeout: 600000Three inputs carry the WarpBuild-specific behavior. profile-name selects the builder profile, which is one dedicated builder virtual machine with its own layer cache. timeout is the wait in milliseconds for that builder to report ready and defaults to 10 minutes, per the Docker builders documentation. On a runner WarpBuild does not operate, including GitHub-hosted runners and a local terminal, add api-key: ${{ secrets.WARPBUILD_API_KEY }} so the action can request a builder assignment.
The docker/setup-buildx-action step is absent on purpose. The Docker builders documentation recommends removing it when its only role was setting up a builder, because the bake action sets one up for you.
Building a single image on demand needs the same step with a different target name rather than a second workflow:
- uses: Warpbuilds/bake-action@v6
with:
files: ./docker-bake.hcl
targets: gateway
profile-name: "platform-builder"
set: |
gateway.platform=linux/amd64When bake beats a matrix of separate build jobs
Two behaviors documented on the Docker builders documentation decide this. First, every job that calls a build action requests its own builder and waits for it to report ready, so the number of attach waits per pipeline run equals the number of jobs. Second, the cache on a builder profile is shared between concurrent builds but eventually consistent, so a layer produced by one in-flight build may not be visible to another build running at the same moment and becomes available to later builds after synchronization. Six matrix jobs that fire together on a cold profile can therefore each build the same shared base stage. One bake invocation resolves that stage once inside a single BuildKit run and reads it from cache for every later target.
| Dimension | Matrix of six build jobs | One bake target group |
|---|---|---|
| Builder attach waits per run | 6, one per job | 1 for the whole run |
| Runner jobs billed | 6 | 1 |
| Shared base stage on a cold profile | Concurrent jobs can each build it, because the profile cache is eventually consistent between in-flight builds | Built once in the run and reused by later targets |
| Builder sessions | 1, shared, billed from the first job's builder start to the last job's completion | 1 |
| Where tags and build args live | Repeated per matrix entry or per action input | In the bake file, inherited from a shared target |
| Effect of one image failing | Remaining jobs keep going | The invocation fails, and a rerun rebuilds the group |
That last row is the case for keeping a matrix. A repository where one flaky image should not block the other five is better served by separate jobs, and bake still helps inside each of them through targets.
Here is the arithmetic on a full month. Assumptions are stated so you can substitute your own measurements: 300 pipeline runs per month, six images per run, a warp-ubuntu-latest-x64-4x runner at $0.008 per minute, and one builder profile at 32 vCPU, 64GB, 200GB disk at $0.12 per minute. Each matrix job holds its runner for 5 minutes across checkout, attach, dispatch, and post-job steps; the single bake job holds its runner for 8 minutes. The builder session runs 6 minutes per pipeline run in both shapes. Rates come from the pricing page and the Docker builders documentation, checked on 2026-08-13.
| Line | Rate | Matrix of six jobs | One bake job |
|---|---|---|---|
| Runner minutes per month | $0.008 per minute | 9,000 | 2,400 |
| Runner cost per month | $72.00 | $19.20 | |
| Builder session minutes per month | $0.12 per minute | 1,800 | 1,800 |
| Builder cost per month | $216.00 | $216.00 | |
| Total per month | $288.00 | $235.20 |
The builder line does not move, and that is the honest shape of this comparison: concurrent jobs on one profile already share a single session, so a matrix does not multiply builder time. The $52.80 monthly difference comes off the runner line, from five jobs that stop existing. Duplicate base-stage work on a cold profile sits on top of that and depends on how much your images share, which is why the monorepo Docker builds guide treats change detection and target selection as the next lever after bake itself.
The remote Docker builder catalog lists every profile size with its rate, and the caching documentation covers the separate WarpBuild cache for dependency directories, which stays useful alongside a builder.
Related Questions
Do I still need docker/setup-buildx-action when I run bake?
No, when the only job that step did was create a builder. The WarpBuild bake action sets up the remote builder for the run, so the setup step comes out of the workflow. Keep it only when you configure a driver or a builder that the action does not create for you. The Docker builders documentation shows the diff for both the bake action and the build and push action.
Can one bake invocation build several architectures?
Yes. Put the architectures in the platforms attribute of a target, and enable both on the builder profile first. Each architecture runs on a separate builder instance, so a multi-arch run opens one session per architecture and both are billed independently. arm64 and multi-arch profiles cap at 64 vCPU, so the largest profile sizes are amd64 only. A profile missing an architecture a target requests produces an exec format error, which is the first thing to check when an arm64 target fails on a profile that used to be amd64 only. The remote Docker builder catalog shows which sizes carry which architectures.
Should I keep cache-from and cache-to in the bake file?
Not for targets that run on a remote Docker builder. The builder holds the layers on its own disk and reuses them for later builds, so the cache export and import move layers the builder already has. Keep the attributes behind a bake variable when some targets still build on the runner's local BuildKit, so one file serves both paths. Docker Bake, defined covers variables and overrides, and Docker builds on GitHub Actions covers the workflow patterns around them.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.