BuildKit

BuildKit is the Docker build engine that resolves a Dockerfile into a graph of build steps, runs independent steps concurrently, and manages the layer cache.

BuildKit is the Docker build engine that reads a Dockerfile, resolves it into a graph of build steps, runs the steps that do not depend on each other at the same time, and manages the layer cache that lets a later build skip work it has already done. Docker Engine 23.0 and later use it as the default builder behind docker build, and it is published as an open source project at github.com/moby/buildkit under the Apache 2.0 license.

The graph is the part worth holding on to. A Dockerfile reads like a script from top to bottom, and BuildKit treats it as a dependency graph instead, which is why two stages that never reference each other run in parallel and why a stage nobody depends on never runs at all.

Definition

BuildKit is a build engine: a daemon that accepts a build definition, executes it, and produces an output. The daemon binary is buildkitd. It ships inside Docker Desktop and Docker Engine, and it can also run on its own in a container, on a Kubernetes cluster, or on a separate machine reached over TCP (BuildKit documentation, checked on 2026-08-13).

A build passes through four parts of the engine in order.

PartInputOutputConsequence for a build
FrontendDockerfile text, build args, target stageAn LLB graphThe # syntax= directive selects the frontend image, so new Dockerfile features arrive by pulling a newer frontend rather than upgrading the daemon.
SolverThe LLB graphAn execution plan with cache decisionsVertices the target does not depend on are pruned before anything runs.
ExecutorPlan verticesFilesystem snapshotsVertices with no edge between them execute at the same time.
ExporterThe final snapshotImage, registry push, tar, local directoryThe output format is a build-time choice rather than a property of the Dockerfile.

LLB stands for low-level build, the intermediate format the frontend emits. It is a content-addressable graph in which each vertex is one operation: run a command in a container, copy files between snapshots, fetch a source such as an image or a Git ref. The Dockerfile is one way to produce that graph, and the frontend interface lets other formats produce it too.

What decides a cache hit

The solver computes a digest for every vertex from the operation itself plus the digests of its parents. A vertex whose digest matches a result already in the cache is skipped, and its stored snapshot is used as the parent of whatever comes next.

Two details follow from that rule and explain most surprising cache misses. A COPY vertex includes the checksums of the files it copies, so editing one source file changes that vertex and every vertex downstream of it. And because parent digests feed child digests, changing an early instruction invalidates the whole tail of that branch even when the later commands are byte-identical.

Mounts

BuildKit extends RUN with a --mount flag that attaches storage to a single command. The mount is visible while that command runs and is absent from the image layer the command produces.

Mount typeWhat it attachesPresent in the image
cacheA directory managed by the builder and reused across builds, such as a package manager download directoryNo
bindA read-only view of another stage or of the build contextNo
secretA file supplied at build time, such as a token or a credentials fileNo
sshA forwarded SSH agent socket for fetching private dependenciesNo
tmpfsAn empty in-memory directory for the duration of the commandNo

The cache type is the one that changes build time the most, because it keeps a populated directory outside the layer graph. A cache mount also takes a sharing option: shared is the default and lets concurrent builds use the mount at once, locked serializes them, and private gives each concurrent build its own copy (Dockerfile reference, checked on 2026-08-13).

Moving the cache between machines

The layer cache lives with the builder by default. To carry it somewhere else, a build passes --cache-to and --cache-from with a backend type: inline writes cache metadata into the pushed image, registry writes a separate cache image, local writes a directory, gha uses the GitHub Actions cache service, and s3 and azblob use object storage. Cache mount contents stay behind on the builder and are excluded from every one of those exports.

Example

This Dockerfile has two stages that reference nothing from each other, and a third that depends on both.

# syntax=docker/dockerfile:1

FROM node:22 AS web
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY web/ ./
RUN npm run build

FROM golang:1.24 AS api
WORKDIR /src/api
COPY api/go.mod api/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY api/ ./
RUN --mount=type=cache,target=/root/.cache/go-build go build -o /out/server ./cmd/server

FROM gcr.io/distroless/base-debian12
COPY --from=api /out/server /server
COPY --from=web /src/web/dist /public
ENTRYPOINT ["/server"]

The solver draws no edge between the web branch and the api branch, so the executor starts npm ci and go mod download at the same time on a builder with capacity for both. The final stage has an edge to each branch, so it waits for the slower one. A build invoked with --target web prunes the api branch entirely and never downloads the Go module set.

The workflow below builds that Dockerfile on a GitHub Actions runner and exports the layer cache to the GitHub Actions cache service so the next run can import it.

name: image
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: example/app:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Trace what the solver does as the repository changes between pushes.

Change since the last buildweb branchapi branch
NothingEvery vertex digest matches the imported cache, so no command runs and the exporter assembles the image from stored snapshots.Same.
One line edited in web/src/App.tsxCOPY web/ ./ changes, so npm run build runs again. The two vertices above it still match, so npm ci is skipped.Untouched, restored from cache.
A package added to web/package-lock.jsonThe lockfile COPY changes, so npm ci runs again. If the builder still holds the /root/.npm mount, the download step reads tarballs from disk and fetches only the new package.Untouched, restored from cache.
A module added to api/go.modUntouched, restored from cache.go mod download and go build run again, reading whatever the /go/pkg/mod and build cache mounts still hold.

The last column of rows three and four carries a condition worth stating plainly. docker/setup-buildx-action creates a builder inside the job, and that builder disappears when the job ends, taking its cache mounts with it. Layer cache survives because cache-to pushed it to an external store, while the npm download directory and the Go module directory start empty on every run. A builder whose storage outlives the job keeps those mounts populated, which is the difference between a cache mount that helps on the second build and one that only ever helps within a single build.

FAQ

What is the difference between BuildKit and Buildx?

BuildKit is the engine that executes a build: it turns the Dockerfile into a graph, schedules the steps, and owns the cache. Buildx is the client-side CLI that decides which BuildKit instance receives the build, creates and lists builders, and passes flags such as platforms and cache backends to the engine.

Do BuildKit cache mounts persist between GitHub Actions runs?

A cache mount lives in the storage of the builder that ran the build, so it lasts exactly as long as that builder does. A builder created inside the job and discarded when the job finishes starts each run with an empty mount. Layer cache exported with cache-to travels between runs through an external store, and cache mount contents are excluded from that export.

How do I tell whether a build used BuildKit?

Docker Engine 23.0 and later run docker build through BuildKit by default, and the giveaway is the grouped step output with per-step timings instead of a flat stream of intermediate container IDs. On older engines the legacy builder ran unless DOCKER_BUILDKIT=1 was set in the environment.

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.