Faster Docker Builds with BuildKit: Cache Mounts, Remote Caching, and Bake

Docker image builds are one of those things that quietly accumulate technical debt. You start with a simple Dockerfile, and before long your builds take 15 minutes, your images are 2 GB, and your CI pipeline is the bottleneck. The good news is that BuildKit — now the default builder since Docker Engine 23.0 — ships a suite of features that most teams never use. Multi-stage builds alone get you part of the way, but the real wins come from cache mounts, remote cache sharing, and build orchestration tools like docker buildx bake.

BuildKit v0.31.0 (released June 2026) and Docker buildx v0.35.0 brought several improvements worth paying attention to: resource limits for CPU and memory on build steps, a network proxy mode for capturing and auditing build-time traffic, OCI media types by default, and local exporter improvements. This post walks through the techniques that have the highest impact on build speed, image size, and CI cost — with working examples in Go.

Start With a Disciplined Multi-Stage Build

Multi-stage builds are table stakes, but the difference between a careless multi-stage build and an intentional one is significant. The goal: your final image should contain only the compiled binary and its runtime dependencies — nothing else. No compiler, no source code, no package manager caches.

Here’s a Go example that compiles a static binary and copies it into a minimal scratch image:

# syntax=docker/dockerfile:1.25
FROM golang:1.25 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server

FROM scratch
COPY --from=builder /app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]

A few things to notice. The # syntax= directive pins the Dockerfile frontend, which gives you access to the latest BuildKit features without upgrading Docker Engine itself. The go mod download step runs before COPY . ., so dependency resolution is cached independently of source code changes. The -ldflags="-s -w" strips debug symbols and DWARF tables, typically shrinking the binary by 30%. The final image is scratch — no shell, no OS, just the binary and CA certs for TLS.

Cache Mounts: The Build-Time Speedup You’re Missing

The RUN --mount=type=cache directive is one of the most underused BuildKit cache mount features. It creates a persistent cache directory that survives across builds without being included in the final image. This is enormously useful for package managers, test caches, and compiler artifacts.

For a Go project, caching the Go build cache and module directory:

# syntax=docker/dockerfile:1.25
FROM golang:1.25 AS builder
WORKDIR /src

# Cache Go modules and build artifacts
ENV GOMODCACHE=/root/.cache/go-mod
ENV GOCACHE=/root/.cache/go-build

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/root/.cache/go-mod \
    go mod download

COPY . .
RUN --mount=type=cache,target=/root/.cache/go-mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server

The cache mount persists between builds on the same builder instance. After the first build, subsequent builds skip recompiling unchanged packages — a significant speedup for large Go projects. You can add id=gocache to share the cache across different targets or Dockerfiles, and sharing=locked to prevent concurrent builds from corrupting shared state.

Secret Mounts for Build-Time Credentials

Hardcoding credentials in a Dockerfile — or passing them as build args — is a well-known antipattern. Build args are visible in image history and layer metadata. The --mount=type=secret directive provides a clean alternative: credentials are available at build time, never stored in layers, and never appear in image metadata.

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

To provide the secret at build time:

docker buildx build --secret id=npmrc,src=$HOME/.npmrc .

Exporting and Sharing Build Cache Across CI Runs

Cache mounts only work on a single builder instance. In CI, where each run may start from a fresh environment, you need to export and import the build cache via a shared cache backend. BuildKit supports several cache export targets: registry (inline or separate), local directory, GitHub Actions cache, S3, and Azure Blob Storage.

The registry type pushes the cache as a separate manifest alongside the image. This is preferable to the inline type because it doesn’t bloat the image and supports multi-stage caches:

docker buildx build \
  --cache-to type=registry,ref=myorg/app:buildcache,mode=max \
  --cache-from type=registry,ref=myorg/app:buildcache \
  -t myorg/app:latest \
  --push .

The mode=max flag is important — by default, BuildKit only exports the cache for the final stage. With max, it exports all intermediate stages, so the next CI run can reuse the Go module download layer even if only a source file changed.

Orchestrating Multi-Service Builds with Bake

When a monorepo has multiple services, each with its own Dockerfile, building them one at a time wastes time. Docker Buildx Bake lets you declare all build targets in a single HCL or JSON file and builds them concurrently with shared caching.

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

target "api" {
  context = "./services/api"
  dockerfile = "Dockerfile"
  tags = ["myorg/api:latest"]
}

target "worker" {
  context = "./services/worker"
  dockerfile = "Dockerfile"
  tags = ["myorg/worker:latest"]
}

target "scheduler" {
  context = "./services/scheduler"
  dockerfile = "Dockerfile"
  tags = ["myorg/scheduler:latest"]
}

A single docker buildx bake builds all three targets in parallel, deduplicating shared base layers automatically. For a monorepo where all services share the same Go base image, this means the base layer is downloaded and unpacked once instead of three times.

Resource Limits: New in BuildKit v0.31.0

BuildKit v0.31.0 (June 2026) introduced per-step resource limits for CPU and memory, accessible through the --resource flag on docker buildx build. This is particularly useful in shared CI environments where a single build step can otherwise consume all available resources and starve concurrent builds:

docker buildx build \
  --resource memory=4g \
  --resource cpu-quota=2000000 \
  .

The --resource flag is repeatable and accepts key=value pairs. Supported keys include memory, memory-swap, cpu-shares, cpu-period, cpu-quota, and cpuset-cpus. The limits apply to each RUN step individually, which matters because BuildKit can execute build steps in parallel. In a CI matrix where multiple builds run concurrently on the same runner, this prevents a single build from monopolizing the host. The feature requires BuildKit v0.31.0 or later.

Network Proxy: Auditing Build-Time Traffic

Also new in BuildKit v0.31.0 is a network proxy mode that routes all container traffic during the build through an HTTP proxy. This allows you to capture and inspect what URLs your build steps contact — useful for supply chain security audits. Source policies can define allowlists and blocklists for network requests during the build.

For example, you can ensure that build steps only contact your private package registry and block everything else. This catches unexpected network calls — whether from a compromised dependency or a misconfigured build script — before they reach production.

OCI Media Types by Default

BuildKit v0.31.0 now defaults to OCI media types for all image results. Previously, this was only applied when annotations or attestations were present. OCI artifacts are the modern standard for container images — they enable cleaner tooling integration and better support for SBOMs, provenance attestations, and signatures. If you need legacy Docker media types for compatibility with an older tool, you can opt out with oci-mediatypes=false.

Putting It Together

The highest-impact changes, in order of return on effort:

  • Multi-stage with scratch final stage — cuts image size by 90%+ for compiled languages
  • Cache mounts for package managers — eliminates redundant downloads and compilations
  • Remote cache with mode=max — gives CI runs near-instant rebuilds after the first build
  • Bake for multi-service repos — parallel builds with shared layer deduplication
  • Resource limits — prevents noisy-neighbor problems in shared CI runners

The cumulative effect is dramatic. A typical Go service that takes 8-10 minutes to build from scratch can drop to under 30 seconds on subsequent CI runs with proper cache sharing. Image sizes shrink from hundreds of megabytes to single digits. And with the network proxy and OCI defaults, you get supply chain visibility for free.

The tooling has matured to the point where there’s no excuse for slow builds or bloated images. The features are all available in stable releases — you just need to use them.

Leave a Reply

Your email address will not be published. Required fields are marked *