Building Several Images from One Monorepo

A monorepo that rebuilds every image on every commit pays for images nobody touched. Detect the changed paths, bake only the affected targets, price both paths.

Last verified:

A monorepo rebuilds every image on every commit because no step in the workflow maps changed files to image targets, so a one line edit under services/api rebuilds all twelve Dockerfiles. The fix is a change-detection job that diffs against the merge base and emits the affected target list, plus a bake file whose targets are selected from that list and built against one builder profile. This guide covers the detection step, the bake file and workflow YAML, and a cost model that prices the build-everything path against the affected-set path.

Diagnosis

Three workflow shapes produce the same bill.

One job running docker buildx bake with no target argument. Bake builds the default group, and that group usually lists every target in the file. The command is short, which is why it survives the growth from three images to twelve.

A matrix with a hardcoded service list. Each service gets its own job and its own build, so the job count tracks the directory count. Adding a service means adding a matrix entry, and nothing in the file ever removes one for a given commit.

A workflow level paths filter. on.push.paths gates the whole workflow rather than individual images, so it cannot express "build api and web but not worker" inside one run. It also creates a second problem: a workflow that never starts reports nothing, and GitHub documents the resulting stuck pull request under troubleshooting required status checks. Teams then mark the check optional, which removes the gate they wanted.

The cost shape is the same in all three. Build minutes track the image count rather than the size of the change, so the bill grows every time somebody adds a service and stays flat when a commit touches one line.

Shared base stages make it worse. If eight of twelve images start from the same builder stage and each image builds in its own job with its own cache, that base stage resolves once per job. Splitting images across jobs to gain parallelism costs you the layer reuse that a single builder would have given for free.

Measuring takes one query against your own history. For each of the last 50 commits on the default branch, record how many image targets built and how many top level service directories the commit touched. When the first number sits at the image count while the second sits at one or two, the workflow is building on a schedule rather than on a change.

Fix

The pattern has three pieces: a detection job that turns a diff into a JSON list of bake targets, a bake file with one target per image plus a group, and one builder profile that all selected targets build against so their shared layers stay warm.

Start with the mapping. Write it once, as data, and keep it next to the bake file.

Changed pathTargets selectedReason
services/api/**apiOne service directory, one image
services/web/**webOne service directory, one image
services/worker/**workerOne service directory, one image
packages/shared/**api, web, workerEvery image vendors this package
Dockerfile.base, docker-bake.hcl, bun.lockevery targetLayers below the service copy step change

Two rules keep that table honest. Anything shared selects every target that depends on it, and anything unmapped selects every target, so a new directory fails safe by building too much instead of shipping a stale image.

The compute the pattern needs, with per-minute rates from the cloud runners documentation and the Docker builders documentation, checked on 2026-08-13:

JobWhere it runsShapeRate per minute
Change detectionwarp-ubuntu-latest-x64-2x2 vCPU, 8GB, 150GB SSD$0.004
Bake driverwarp-ubuntu-latest-x64-4x4 vCPU, 16GB, 150GB SSD$0.008
Image buildBuilder profile32 vCPU, 64GB RAM, 200GB disk$0.12
Required check gatewarp-ubuntu-latest-x64-2x2 vCPU, 8GB, 150GB SSD$0.004

The driver job label is the only thing that changes if the images need an ARM64 host. Builder profiles carry the layer cache across runs, and the cache resets after 10 days without use. Multi architecture and arm64 profiles top out at 64 vCPU; the 96 and 192 vCPU sizes, $0.36 to $0.88 per minute, are amd64 only.

One profile for all the targets in a repository is the default worth starting from. Concurrent jobs on one profile share a builder VM and a session, so twelve targets that share a base stage resolve that stage against one cache rather than twelve. Split into a second profile only when two image families share no layers and contend for disk.

Configuration

The bake file names one target per image and inherits shared settings from a hidden target.

# docker-bake.hcl
variable "TAG" {
  default = "dev"
}

variable "REGISTRY" {
  default = "ghcr.io/acme"
}

target "_common" {
  context   = "."
  platforms = ["linux/amd64"]
  cache-from = ["type=registry,ref=${REGISTRY}/cache"]
}

target "api" {
  inherits   = ["_common"]
  dockerfile = "services/api/Dockerfile"
  tags       = ["${REGISTRY}/api:${TAG}"]
}

target "web" {
  inherits   = ["_common"]
  dockerfile = "services/web/Dockerfile"
  tags       = ["${REGISTRY}/web:${TAG}"]
}

target "worker" {
  inherits   = ["_common"]
  dockerfile = "services/worker/Dockerfile"
  tags       = ["${REGISTRY}/worker:${TAG}"]
}

group "default" {
  targets = ["api", "web", "worker"]
}

The default group stays complete so a local docker buildx bake still builds everything. The workflow overrides it by naming targets on the command line, which is what makes the same file usable from a laptop and from GitHub Actions.

The detection job diffs against the merge base and writes a JSON array to a job output.

name: images
on:
  push:
    branches: [main]
  pull_request:

env:
  ALL_TARGETS: '["api","web","worker"]'

jobs:
  changed:
    runs-on: warp-ubuntu-latest-x64-2x
    outputs:
      targets: ${{ steps.select.outputs.targets }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - id: select
        env:
          BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
        run: |
          set -euo pipefail
          if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
            echo "targets=$ALL_TARGETS" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          changed=$(git diff --name-only "$BASE" "$GITHUB_SHA")
          if grep -qE '^(Dockerfile\.base|docker-bake\.hcl|bun\.lock|packages/shared/)' <<< "$changed"; then
            echo "targets=$ALL_TARGETS" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          selected=()
          for svc in api web worker; do
            if grep -q "^services/$svc/" <<< "$changed"; then
              selected+=("$svc")
            fi
          done
          if [ ${#selected[@]} -eq 0 ]; then
            targets='[]'
          else
            targets=$(printf '%s\n' "${selected[@]}" | jq -Rsc 'split("\n") | map(select(length > 0))')
          fi
          echo "targets=$targets" >> "$GITHUB_OUTPUT"

fetch-depth: 0 matters. The default shallow clone gives the job one commit, and git diff against a base SHA the runner never fetched fails with bad object. The zero SHA guard covers the first push to a new branch, where github.event.before carries no parent and the safe answer is to build everything.

The build job expands that array into bake target arguments and points at one builder profile.

  build:
    needs: changed
    if: needs.changed.outputs.targets != '[]'
    runs-on: warp-ubuntu-latest-x64-4x
    env:
      TAG: ${{ github.sha }}
      REGISTRY: ghcr.io/${{ github.repository_owner }}
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Bake the affected targets
        uses: Warpbuilds/bake-action@v6
        with:
          files: docker-bake.hcl
          targets: ${{ join(fromJSON(needs.changed.outputs.targets), ',') }}
          push: ${{ github.event_name == 'push' }}
          profile-name: "monorepo-images"
          api-key: ${{ secrets.WARPBUILD_API_KEY }} # Not required on WarpBuild runners
          timeout: 600000

  images:
    needs: [changed, build]
    if: always()
    runs-on: warp-ubuntu-latest-x64-2x
    steps:
      - name: Report the aggregate result
        run: |
          result="${{ needs.build.result }}"
          if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
            echo "build job result: $result"
            exit 1
          fi
          echo "build job result: $result"

Warpbuilds/bake-action is a drop in replacement for docker/bake-action, so files, targets, push, and set keep their meaning, and the action wires up the remote builder itself. Drop docker/setup-buildx-action when it was only there to create a builder. The TAG and REGISTRY job environment variables feed the HCL variable blocks, so the same bake file tags by commit SHA in the workflow and by dev on a laptop.

The images job is what a branch protection rule should require. It runs on every commit, including the ones where build was skipped, so the check reports rather than hanging. When you need the same discipline for tests and lint alongside images, the monorepo pipelines guide covers the wider fan out, and running a job only when files change covers the single job case.

Two details worth knowing before this reaches a busy repository. cache-to and cache-from are not required with a builder profile, since the profile caches layers and reuses them on the next build; keep the registry cache entry in _common only if builds also run outside GitHub Actions. And bake target names become part of your public interface the moment the mapping table references them, so rename them the same way you rename a package. The bake reference documents target inheritance and group expansion, and the caching documentation covers the dependency cache that sits alongside the layer cache.

CI observability is the practical one here, because it correlates system metrics from the runner agent with GitHub Actions job logs, which shows whether the driver job is waiting on the builder or on the registry push.

Cost or Time Model

Take a repository with twelve images, 300 commits per month on the default branch, and a change profile where the average commit touches two services. Bake all twelve targets and the builder session runs 14 minutes; bake two and it runs 4 minutes. Rates are from the pricing page and the Docker builders documentation, checked on 2026-08-13. Substitute your own durations, because the ratio between the two paths is what the model is for.

PathArithmeticCost per commitWall clock
Build every image(14 x $0.12) + (14 x $0.008)$1.79214 min
Build the affected set(4 x $0.12) + (4 x $0.008) + (1 x $0.004)$0.5165 min

The affected-set path adds one minute of detection at $0.004 and removes ten minutes of builder session. Across 300 commits that is $537.60 against $154.80, a difference of $382.80 per month, and 2,700 minutes of pipeline time returned, roughly 45 hours.

The model moves with the change profile rather than with the image count, so run it against your own history.

Average services touchedSession minutesCost per commitMonthly at 300 commits
13$0.388$116.40
24$0.516$154.80
47$0.900$270.00
810$1.284$385.20
12 (every image)14$1.796$538.80

Read the last row against the first table. When every commit touches every image, detection costs $0.004 and returns nothing, which is the honest break-even. The pattern earns its keep when the bottom rows are rare, and the way to check is the 50 commit sample from the diagnosis section rather than an assumption about how the team works.

Two levers sit underneath the table. Profile size trades rate against session length: a 16 vCPU profile at $0.06 per minute halves the rate and lengthens the session, so it prices better on IO bound images and worse on compile bound ones. A repository that bakes every target on every commit keeps the cache warm by accident; a repository on the affected-set path keeps it warm on purpose by sending all targets to one profile.

The GitHub Actions job and the builder session are separate resources and both are billed, which is why the driver runner stays at 4 vCPU in every row above. Sizing the driver up buys nothing when the build runs on the builder. For the wider Docker picture on GitHub Actions, including registry choice and cache strategy, see Docker builds on GitHub Actions, and for the case where several images build at once, see concurrent Docker builds. The target syntax itself is covered in Docker bake.

FAQ

Which images should build when a shared base image changes?

All of them. Treat the base Dockerfile, the bake file, and the lockfile as global triggers that select every target, because a change in any of the three invalidates layers in every image that inherits from them. Everything else maps one service directory to one bake target.

Why does the workflow need a job that always runs?

A skipped job reports nothing to a required status check, so a pull request whose build job was filtered out waits forever. Add a final job with if: always() that reads the build job result and exits non-zero on failure or cancellation, then make that job the required check.

Can one builder profile handle several bake targets in the same run?

Yes. Concurrent jobs on one builder profile run on the same builder VM and bill as one session, measured from the first job start to the last job completion. The layer cache is shared across those jobs and is eventually consistent, so a layer written by one build may reach the next build rather than the concurrent one.

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.