BuildKit

Practical notes on what BuildKit actually does differently from the classic Docker builder: it compiles a Dockerfile into a dependency DAG instead of a linear instruction list, runs independent stages in parallel, and gives you mount types (cache, secret, ssh) that never end up baked into a layer. Each major section below ends with a quick check — try to answer before revealing:

0/0 checks

1. Architecture

graph TD
    CLI["docker buildx / CLI"] --> GW[BuildKit Gateway]
    GW --> LLBGEN[LLB IR Generator]
    LLBGEN --> SOLVER[DAG Solver]
    SOLVER --> W1[Worker: snapshotter]
    SOLVER --> W2[Worker: executor]
    W1 --> CACHE["Content-addressable<br/>cache store"]
    W2 --> SNAP["Overlay snapshotter<br/>overlayfs / fuse"]
    SNAP --> IMG["Output: image / OCI"]
  • buildkitd: daemon that receives LLB graphs and executes them
  • LLB (Low-Level Build): protobuf IR — a DAG of operations (exec, copy, mount)
  • Snapshotter: manages layer snapshots (overlayfs, native, fuse-overlayfs)
  • Worker: executes each LLB operation in isolation

Does the CLI send buildkitd the Dockerfile itself to build?

2. Parallel Stage Execution

Classic builder executes stages sequentially. BuildKit builds the dependency DAG and runs independent stages in parallel.

FROM golang:1.22 AS builder
RUN go build -o /app .

FROM node:20 AS frontend       # independent of builder
RUN npm ci && npm run build

FROM alpine AS final
COPY --from=builder /app /app
COPY --from=frontend /dist /static
gantt
    title BuildKit parallel vs sequential
    dateFormat X
    axisFormat %s s

    section Classic Builder
    base image pull   : 0, 3
    builder stage     : 3, 8
    frontend stage    : 8, 13
    final stage       : 13, 15

    section BuildKit
    builder stage     : 0, 5
    frontend stage    : 0, 4
    final stage       : 5, 7

BuildKit detects that builder and frontend have no dependency → runs them concurrently.

Step through what actually happens between t=0 and t=7 in the timeline above:

1. Dockerfile parsed into a DAG. BuildKit walks every FROM ... AS <name> stage and every COPY --from=<name>, building a dependency graph instead of a flat instruction list.
2. Independent stages launch together (t=0s). builder and frontend share no edge in the DAG — each starts pulling its base image and running its own steps immediately, on separate workers.
3. Each stage finishes on its own clock. frontend finishes at t=4s, builder at t=5s — neither one waited on the other, so the slower of the two sets the pace, not the sum of both.
4. final starts once its dependencies are ready (t=5s). Its two COPY --from instructions need builder and frontend respectively, so it can't start until both are done — but it doesn't wait on anything else. Total time is max(builder, frontend) + final, not builder + frontend + final.

final copies from both builder and frontend. Does that mean final also runs in parallel with them?

3. Cache Mounts

Cache mounts persist across builds — the directory is not part of the image layer.

# Go modules cache
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /app .

# apt cache
RUN --mount=type=cache,target=/var/cache/apt \
    apt-get update && apt-get install -y curl

# npm cache
RUN --mount=type=cache,target=/root/.npm \
    npm ci
sequenceDiagram
    participant D as Dockerfile RUN
    participant C as Cache mount<br/>(host volume)
    participant L as Image layer

    D->>C: read cached deps
    C-->>D: cache hit (fast)
    D->>D: build / compile
    D->>L: write compiled output only
    Note over C,L: Cache mount NOT in layer

Same flow, one step at a time:

1. RUN starts, cache mount requested. BuildKit sees --mount=type=cache,target=/go/pkg/mod and resolves that target against its cache store instead of the image filesystem.
2. Matching cache found → mounted read-write. If a cache blob for that target (and sharing mode) already exists on this worker, it's attached at /go/pkg/mod before the command runs — a cache hit.
3. The command reads and writes the mount directly. go build / npm ci populate or reuse /go/pkg/mod like any other directory — from its point of view, nothing looks different from a normal build.
4. RUN finishes → only non-mount changes become the layer. Whatever landed inside /go/pkg/mod stays in the cache store for the next build; it's never committed into this image's layer. Only files written elsewhere in the filesystem (the compiled binary) end up in the layer.

Cache scope options control what happens when two builds want the same cache mount at once:

RUN --mount=type=cache,target=/go/pkg/mod,sharing=shared
All concurrent builds share one cache directory. Fastest, but two builds writing at once can race inside it — fine for package managers that are themselves safe for concurrent access.
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked
Same cache directory, but only one build may hold it at a time — others block until it's free. Use when the tool writing into the cache isn't safe for concurrent writers.
RUN --mount=type=cache,target=/go/pkg/mod,sharing=private
Each concurrent build gets its own copy of the cache instead of contending for one. No races, but no sharing either — every parallel build pays its own cache-warm cost.

A RUN step uses --mount=type=cache,target=/go/pkg/mod. After the build finishes, is /go/pkg/mod's contents part of the final image layer?

4. Secret Mounts

Secrets are available only during the RUN step — never written to any layer.

RUN --mount=type=secret,id=gh_token \
    GITHUB_TOKEN=$(cat /run/secrets/gh_token) \
    GONOSUMCHECK=* GOFLAGS=-mod=mod \
    go mod download
docker buildx build \
  --secret id=gh_token,env=GITHUB_TOKEN \
  -t myapp:latest .

Verification — secret is absent from history:

docker history myapp:latest   # no token visible
docker save myapp:latest | tar xO | strings | grep -c TOKEN  # 0

After RUN --mount=type=secret,id=gh_token ... runs, will docker history or the exported image tarball contain the token?

5. SSH Agent Forwarding

For private Git repos without exposing keys:

FROM golang:1.22
RUN mkdir -p -m 0700 ~/.ssh && \
    ssh-keyscan github.com >> ~/.ssh/known_hosts
RUN --mount=type=ssh \
    git clone git@github.com:myorg/private-repo.git
eval $(ssh-agent)
ssh-add ~/.ssh/id_ed25519

docker buildx build \
  --ssh default=$SSH_AUTH_SOCK \
  -t myapp:latest .

Does --mount=type=ssh copy your private key file into the image at any point?

Three different --mount types solve three different "don't let this end up in a layer" problems — quick comparison:

Persist something across builds. Package manager caches (Go modules, npm, apt). Survives between builds on the same worker; never becomes part of the image. Reuse is the whole feature.
Use a credential once, then forget it. Tokens, API keys, .npmrc auth. Available only inside the one RUN step that mounts it — not persisted anywhere, not written to any layer.
Authenticate as *you*, without shipping your key. Private git clones over SSH. Forwards your running ssh-agent socket into the step; the private key itself never crosses into the build container or the image.

6. Multi-Platform Builds

# Create a multi-platform builder
docker buildx create --name multiarch --driver docker-container --use
docker buildx inspect --bootstrap

# Build for multiple platforms and push
docker buildx build \
  --platform linux/amd64,linux/arm64,linux/arm/v7 \
  --push \
  -t myorg/myapp:v1.0.0 .

BuildKit uses QEMU for cross-compilation when native hardware is unavailable. For Go, prefer CGO_ENABLED=0 with GOARCH set to avoid QEMU overhead:

FROM --platform=$BUILDPLATFORM golang:1.22 AS builder
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /app .

7. Inline Cache

Push cache metadata to the registry alongside the image:

# Push image + cache metadata
docker buildx build \
  --cache-to type=registry,ref=myorg/myapp:cache,mode=max \
  --push -t myorg/myapp:latest .

# Pull cache on next build (CI)
docker buildx build \
  --cache-from type=registry,ref=myorg/myapp:cache \
  --push -t myorg/myapp:latest .

mode=max exports cache for all intermediate layers (not just final). Use in CI for maximum reuse.

Other cache backends:

# Local filesystem cache
--cache-to type=local,dest=/tmp/buildcache,mode=max
--cache-from type=local,src=/tmp/buildcache

# GitHub Actions cache
--cache-to type=gha,mode=max
--cache-from type=gha

8. Output Types

# Default: image in local Docker daemon
docker buildx build -t myapp:latest .

# OCI tarball (portable)
docker buildx build --output type=oci,dest=./myapp.tar .

# Plain tarball
docker buildx build --output type=tar,dest=./myapp.tar .

# Local directory (extract filesystem)
docker buildx build --output type=local,dest=./out .

# Push directly to registry
docker buildx build --output type=image,push=true -t myorg/myapp:latest .
Output type Use case
image Standard Docker image, push to registry
oci OCI-compliant tar, use with skopeo, podman
tar Raw filesystem archive
local Extract build artifacts to host directory