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:
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?
buildx/the CLI first translates the Dockerfile into LLB — a protobuf DAG of low-level operations (exec, copy, mount). The DAG Solver parallelizes, caches, and schedules that graph across workers; it never reasons about Dockerfile syntax directly.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:
FROM ... AS <name> stage and every COPY --from=<name>,
building a dependency graph instead of a flat instruction list.
builder and frontend share no edge in the DAG —
each starts pulling its base image and running its own steps immediately,
on separate workers.
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.
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?
builder and frontend have no relationship, so they run concurrently. final depends on both of them (via COPY --from), so it can only start once both finish — that's why it starts at t=5s in the gantt chart above, not t=0.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:
--mount=type=cache,target=/go/pkg/mod and resolves that
target against its cache store instead of the image filesystem.
/go/pkg/mod before the command
runs — a cache hit.
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.
/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?
/run/secrets/gh_token for the duration of that one RUN step; it's never written to a layer, so it doesn't show up in history or in a docker save tarball. That's exactly what the verification commands above check for.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?
ssh-agent's socket into the build step over a Unix socket — the key material itself never leaves your machine or touches the image filesystem. The image only needs known_hosts populated (via ssh-keyscan) so the git clone doesn't hang on a host-key prompt.Three different --mount types solve three different "don't let this end up in a layer" problems — quick comparison:
.npmrc auth. Available only inside the one RUN
step that mounts it — not persisted anywhere, not written to any layer.
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 |