Multi Stage Docker Builds and Layer Reuse

Multi stage Docker builds reuse layers one stage at a time, and GitHub Actions throws that away every job. Keep the stages on a builder that outlives the job.

Last verified:

A multi stage Docker build reuses cache one stage at a time: BuildKit rebuilds the stages whose inputs changed plus the stages that copy from them, and it skips every stage the selected target never reaches. On GitHub Actions that reuse is thrown away on each run, because the job creates a fresh buildx instance whose layer store starts empty, so the durable fix is to run the stages on a builder that outlives the job and keeps its layers on local disk.

Per-minute rates for job runners and builder profiles are on the pricing page, and the model at the end of this guide runs them through a Go service with a 1.4 GB build stage and a 28 MB release image.

This guide covers stage-level cache behavior, a Dockerfile and workflow pair that keeps a test image and a release image sharing layers, the builder-side facts that make the reuse durable, and a time and cost model you can re-run with your own step timings. For the wider picture, start at Docker builds on GitHub Actions.

Diagnosis

How BuildKit decides what to rebuild

Every instruction gets a cache key built from its parent chain, the instruction text, and the digests of its inputs. When a key changes, that instruction rebuilds along with every instruction after it inside the same stage.

Invalidation crosses a stage boundary in exactly two ways: a FROM x AS y that names the changed stage, and a COPY --from=x that reads files out of it. A stage that sits outside both paths is untouched, whatever else changed in the file.

That makes stage layout a cache decision. Work placed above the source copy is shared by every stage descended from it, and work placed below is repeated by each descendant that repeats the copy.

Which stages survive a source change

Take a four stage layout: deps resolves modules, build compiles, test runs the suite, and release assembles a minimal runtime image from the compiled binary. Both build and test inherit from deps with FROM deps AS ..., and release reads the binary with COPY --from=build.

StageDirect inputsRebuilds whenEffect downstream
depsgo.mod, go.sum, the base imageThe lockfile or the base image tag movesbuild and test both rebuild
builddeps, the source treeAny file in the build context changesrelease re-evaluates its COPY --from
testdeps, the source treeAny file in the build context changesNothing reads from it, so nothing follows
releaseThe binary copied from buildThe copied binary's checksum changesFinal image layers change, push size changes

The row that surprises teams is test. It is expensive, it reruns on every commit, and it never invalidates the shipped image, so it can run as its own job against the same warm deps layers instead of sitting inside the release path.

Why a changed build stage does not always invalidate the release stage

BuildKit keys a COPY --from on the checksum of the files it copies rather than on whether the source stage reran. A rebuilt build stage that emits a byte-identical binary therefore leaves the release stage on cache.

Reproducible compiler output is what turns that from theory into cache hits. For Go, -trimpath strips absolute paths and -ldflags="-buildid=" removes the build id; for other toolchains, SOURCE_DATE_EPOCH and a deterministic archive order do the same job. Without them the binary differs on every run even when the source did not move, and the release layers rebuild and repush for nothing.

What GitHub Actions costs you here

Four losses stack on a standard hosted job.

The buildx instance is created inside the job and destroyed with the machine, so all four stages start cold on every run. A commit touching one .go file re-resolves modules.

Cache export mode hides the intermediate stages. The default mode=min exports the layers of the final image only, so deps and build are absent from the cache even on a run that reports a cache hit. Switching to mode=max stores them and inflates the export, which then competes with the size limits of the backend you pointed it at. The trade between those backends is worked through in choosing a buildx cache backend on GitHub Actions.

Single stage Dockerfiles push the toolchain. When the compiler, the module cache, and the source tree stay in the shipped image, every run moves gigabytes to the registry instead of the tens of megabytes a release stage produces.

Stage parallelism has nowhere to run. BuildKit executes independent stages concurrently, so build and test can overlap, and a 2 vCPU job runner has no cores to give them.

Read your build log before changing anything. Lines like CACHED [deps 3/3] tell you the stage was reused. If deps prints as executed on a commit that touched one source file, the cache did not survive the job, and no Dockerfile edit will fix that. The layer cache invalidation guide covers the Dockerfile-side causes when the cache does survive and still misses.

Fix

Two moves, in order. Give the expensive work a stage that several targets inherit, then run the build where that stage's layers persist between jobs.

Here is the Dockerfile. deps sits above the source copy, build and test both inherit it, and release carries only the binary.

# syntax=docker/dockerfile:1.7

FROM golang:1.24-bookworm AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download

FROM deps AS build
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -buildid=" \
      -o /out/api ./cmd/api

FROM deps AS test
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go test ./...

FROM gcr.io/distroless/static-debian12:nonroot AS release
COPY --from=build /out/api /usr/local/bin/api
USER nonroot
ENTRYPOINT ["/usr/local/bin/api"]

Three details carry the reuse. The lockfile is copied on its own so go mod download only reruns when dependencies move. The two --mount=type=cache directories hold the module cache and the compiler cache outside the layer graph, so a rebuilt build stage still compiles incrementally. And the release stage starts from a distroless base, so the shipped image contains the binary and nothing from the toolchain.

Now the workflow. Both jobs name the same builder profile, so both reach the same builder VM and the same warm deps layers.

name: docker

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: warp-ubuntu-latest-x64-2x
    steps:
      - uses: actions/checkout@v5

      - uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          target: test
          push: false
          profile-name: api-amd64
          timeout: 600000

  release:
    runs-on: warp-ubuntu-latest-x64-2x
    steps:
      - uses: actions/checkout@v5

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

      - uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          target: release
          push: ${{ github.event_name == 'push' }}
          tags: ghcr.io/acme/api:${{ github.sha }}
          profile-name: api-amd64
          timeout: 600000

A WarpBuild builder profile is one dedicated Docker builder VM with a persistent layer cache on its local disk, addressed from the workflow by profile-name. Jobs come and go; the profile and every stage it has already built stay. The cache has a TTL of 10 days, so a profile that goes unused for more than 10 days is reset automatically and the next build repopulates all four stages.

Note the two omissions against a hosted setup. There is no docker/setup-buildx-action step, because the WarpBuild action configures the remote builder itself. There are no cache-from and cache-to lines, because the intermediate stages already live on the builder disk, so the export and import round trip buys nothing.

The job runner shrinks with the work. Both jobs check out the repository, hand the build to the builder, and wait, which warp-ubuntu-latest-x64-2x covers. Pick a Linux label here, since macOS runners have no nested virtualization and cannot run Docker.

One honest caveat on the parallel split. The layer cache between concurrent builds is eventually consistent, so on a cold profile the test job and the release job may each build deps rather than sharing one execution of it. From the second run on, both hit.

Configuration

Action inputs that decide stage selection

InputValueNotes
targetStage name from the DockerfileBuilds that stage and its ancestors only. Omit it and BuildKit builds the last stage in the file.
profile-nameName of the builder profileSelects the builder VM and therefore which cached stages you reach. Required.
api-key${{ secrets.WARPBUILD_API_KEY }}Not required on WarpBuild runners. Required from a GitHub-hosted runner or any other machine.
timeoutMilliseconds to wait for the builderDefaults to 10 minutes for Warpbuilds/build-push-action.
platformslinux/amd64,linux/arm64Both architectures must be enabled on the profile, otherwise the build fails with exec format error.

Building several targets in one invocation

When targets multiply, a bake file keeps the stage graph in one place and lets Warpbuilds/bake-action@v6 drive it:

group "default" {
  targets = ["test", "release"]
}

target "test" {
  context = "."
  target  = "test"
  output  = ["type=cacheonly"]
}

target "release" {
  context   = "."
  target    = "release"
  platforms = ["linux/amd64"]
  tags      = ["ghcr.io/acme/api:latest"]
}

Both targets resolve against the same deps stage inside one builder session, which removes the eventual consistency caveat above at the cost of coupling the two into one job. If your build needs custom steps between setup and docker build, use Warpbuilds/docker-configure@v1 instead and invoke it immediately before the build step, since the builder is billed from the moment it is assigned.

What resets the cached stages

  • Ten days without a build on that profile. The TTL applies to the profile rather than to any single stage.
  • An explicit reset. A profile whose disk is full of dead layers after a base image bump can be reset through the API:
curl -s -H "Authorization: Bearer $WARPBUILD_API_KEY" \
  "https://api.warpbuild.com/api/v1/builder-profiles?per_page=30&page=1"

curl -s -X POST \
  -H "Authorization: Bearer $WARPBUILD_API_KEY" \
  "https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID/cache/reset"
  • A different profile. Each profile is one VM with one cache, and two profiles never share stages.

Sizing and concurrency

Roughly 8 vCPU and 16 GB per concurrent build job is the recommended floor, so a 16 vCPU profile comfortably carries the two jobs above. Run as many jobs as your workflows need, because generally available Linux and Windows runners do not have plan-level concurrency caps. For multi-arch profiles, each architecture runs on a separate builder instance, which means one session per architecture, and arm64 and multi-arch profiles top out at 64 vCPU.

The builder and the job runner are two separate resources and both are billed. Full input lists, the CLI flow, and the session billing rules are in the remote Docker builders documentation. Dependency caches that live outside the image, such as a downloaded SDK or a test fixture set, belong in WarpBuilds/cache@v1 from the WarpBuild caching documentation rather than in the layer graph.

Cost or Time Model

The model below is arithmetic on stated assumptions rather than a measurement. Replace each row with the durations printed in your own build log before you use the result to decide anything.

Assumptions

  • A Go API service with 240 modules, a 1.4 GB build stage from golang:1.24-bookworm, and a 28 MB distroless release image.
  • Step durations held constant across every option, so the layer cache is the only variable that moves.
  • 500 pull request builds per month, one in ten cold: 50 cold and 450 warm. Cold means a lockfile or base image change that invalidates deps.
  • 0.5 minutes of per-job overhead for checkout, registry login, and action setup.
  • Concurrent jobs on one profile bill as a single session from the first job's start to the last job's completion, with starts staggered by 0.5 minutes.

Single stage rebuild against staged build with warm layers

StepSingle stage, every runMulti stage, cold profileMulti stage, warm profile
Pull golang:1.24-bookworm base0:200:200:00
go mod download, 240 modules1:051:050:00
Copy source into the stage0:040:040:04
go build with the compiler cache mount2:402:400:48
go test ./...3:103:101:35
Export and push1:20 at 1.4 GB0:22 at 28 MB0:22 at 28 MB
Total8:397:412:49

Two rows do most of the work. go mod download disappears on a warm profile because deps sits above the source copy and the commit did not touch the lockfile. Export drops from 1:20 to 0:22 because the release stage ships 28 MB where the single stage image ships the whole toolchain.

Split across the two jobs, a warm pull request runs a 1:39 test job and a 1:14 release job in parallel against the same profile, so the check goes green in about 2.2 minutes including job overhead, against 9.2 minutes for the single stage build.

Rates used

Job runners, from the WarpBuild pricing page:

Runner labelShapePrice per minute
warp-ubuntu-latest-x64-2x2 vCPU, 8 GB$0.004
warp-ubuntu-latest-x64-4x4 vCPU, 16 GB$0.008
warp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.016
warp-ubuntu-latest-x64-16x16 vCPU, 64 GB$0.032
warp-ubuntu-latest-x64-32x32 vCPU, 128 GB$0.064

Builder profiles, billed per session, with the full catalog on remote Docker builder sizes and pricing:

Builder sizeDiskPrice per minuteArchitectures
16 vCPU, 32 GB100GB$0.06amd64, arm64, multi
32 vCPU, 64 GB200GB$0.12amd64, arm64, multi
64 vCPU, 128 GB200GB$0.24amd64, arm64, multi
96 vCPU, 192 GB600GB$0.36amd64
96 vCPU, 192 GB2TB$0.52amd64
192 vCPU, 384 GB600GB$0.72amd64
192 vCPU, 384 GB2TB$0.88amd64

The GitHub-hosted baseline for a single stage build at the same compute shape as a 16 vCPU builder is the 16-core Linux larger runner at $0.042 per minute, from the GitHub Actions billing reference, checked on 2026-08-13. At that shape warp-ubuntu-latest-x64-16x costs $0.032 per minute, which is $0.010 per minute lower on list price for 16 vCPU and 64 GB.

Monthly model, 500 builds

OptionJob minutesBuilder minutesMonthly cost
A. Single stage, in-job buildx, GitHub 16-core larger runner4,575 at $0.0420$192.15
B. Single stage, in-job buildx, warp-ubuntu-latest-x64-16x4,575 at $0.0320$146.40
C. Multi stage, two warp-ubuntu-latest-x64-2x jobs plus a 16 vCPU profile2,255.8 at $0.0041,225 at $0.06$82.52

Reading it straight: option C costs $63.88 less per month than option B at the same compute shape, and it returns the check in about 2.2 minutes instead of 9.2. The builder minutes carry most of the C total at $73.50, because a session covers both jobs from the first start to the last completion while the two 2 vCPU job runners add $9.02 between them.

Two levers move the result. Session sharing is the first: more pull request jobs overlapping on one profile spread the same session cost, which is why splitting test and release across two jobs on one profile costs less than giving each its own profile. Stage layout is the second: every minute of work you lift above the source copy moves from the 450 warm runs into the 50 cold ones.

FAQ

Does a multi stage Dockerfile build every stage?

No. BuildKit builds only the stages the selected target depends on, plus any stage pulled in by a COPY --from. Building with target: test never runs the release stage, and building with target: release never runs the test stage.

Why does a change in my builder stage invalidate the release stage?

Because the release stage copies from it. BuildKit keys a COPY --from on the checksum of the files it copies, so a rebuilt builder stage that emits a byte-identical binary still lets the release stage hit cache. Embedded timestamps, build ids, and non-deterministic archive ordering change that checksum on every run and force the rebuild.

Do I still need cache-to mode=max for the intermediate stages?

No. A builder profile keeps every stage's layers on the builder VM's own disk, so intermediate stages such as deps and build persist without an export. Remove cache-from and cache-to; they add an import and export round trip that the persistent cache already covers.

How long do the cached stages stay on a builder profile?

The builder cache has a TTL of 10 days. A profile that goes unused for more than 10 days is reset automatically, and the next build repopulates every stage. You can also reset a profile's cache on demand through the WarpBuild API.

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.