How to Speed Up Docker Builds With BuildKit Caching
A Docker image build can spend most of its time doing work that an earlier build already completed: resolving dependencies, downloading packages, compiling source, or assembling intermediate artifacts. BuildKit offers several ways to avoid repeating that work, but the mechanisms are not interchangeable. A reusable image layer, a persistent package-manager directory, and a remotely exported CI cache solve different problems.
The practical objective is not the shortest possible warm build. It is a reliable reduction in elapsed time across the changes developers actually make, without hiding stale dependencies, increasing registry traffic unnecessarily, or leaking credentials. That requires understanding which inputs invalidate each step and where cached data survives.
Understand the three kinds of reuse
BuildKit, Docker's modern build backend, constructs a dependency graph for the build. Its BuildKit documentation describes a concurrent solver that can avoid unused stages and schedule independent work without following every possible Dockerfile step serially. That improves the execution model, but the central performance question remains: which work can be reused?
Layer cache reuses a result when the instruction and its relevant inputs match a previously completed step. A cached dependency installation is effectively skipped, rather than rerun with faster package downloads.
Cache mounts are mutable directories made available to a RUN instruction. If the instruction must execute again, the package manager may find downloaded artifacts or compiler intermediates waiting in its cache directory. This can reduce work inside an otherwise uncached step. The mount's contents are not part of the resulting image layer.
External build cache exports reusable BuildKit records to a registry, local directory, or CI-oriented service so another builder can import them. This matters when a CI worker has no persistent local cache. It is not, by itself, a way to export the mutable files inside cache mounts.
These distinctions determine which optimization belongs in the Dockerfile and which belongs in CI configuration.
Start with a cache-miss diagnosis
Begin with the actual workload, not a generic Dockerfile rewrite. Build an image once, then build it again without changes. Repeat after changing only application source, and again after changing a dependency lockfile. Compare the steps that execute and the time spent importing or exporting cache.
For command-line inspection, use plain build progress:
docker buildx build --progress=plain -t example/app:dev .
The log distinguishes cache hits from executed instructions. It also shows whether downloading a context, pulling a base image, installing dependencies, compiling code, or exporting the resulting image dominates the run. Record total wall-clock time rather than summing step durations: independent steps may overlap.
A useful measurement matrix has at least four cases: a cold builder, an unchanged second build, a source-only change, and a dependency change. CI environments add a fifth: a new worker that imports cache from a remote backend. For multi-platform builds, record the requested target platforms as well; reusing results across architectures is not automatic.
Do not treat a cache hit as evidence of freshness. Docker's cache invalidation rules explain that a cached RUN instruction does not check whether remote package repositories have changed. A command that installed an old package yesterday can remain cached today unless an input or explicit invalidation changes.
Put stable inputs ahead of frequently edited files
Consider a Node.js service that copies the whole repository before installing dependencies:
FROM node:22-bookworm-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
This arrangement makes every source edit a possible invalidation of the COPY step before npm ci. A dependency installation might run again even when package-lock.json did not change.
A better ordering isolates dependency inputs:
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
Now, a change to an application file need not invalidate the earlier dependency installation. Changing the lockfile will invalidate it, which is the desired behavior. This pattern is described in Docker's cache optimization guidance.
The precise ordering depends on the build system. A Python build may copy pyproject.toml and its lockfile before source. A Go build may download modules after copying go.mod and go.sum. A Java or Rust build may have additional dependency metadata and generated files that need to be included in the cache key. The principle is stable: separate the inputs defining dependencies from files changed for ordinary feature work.
Avoid replacing one broad COPY with a dozen brittle instructions purely to create more layers. Use boundaries that correspond to genuinely different change frequencies and meaningful build outputs.
Account for monorepos and generated dependencies
In a monorepo, "copy the manifest first" may not be sufficient. A service can depend on workspace manifests, a root lockfile, shared libraries, code generators, or an internal package whose contents influence the installation step. A cache key that omits one of those inputs is not an optimization; it risks rebuilding with a dependency graph that no longer matches the source.
Use a narrowly defined dependency subset instead of blindly copying the repository root. For example, a workspace-based Node build may need the root package manifest, root lockfile, package-manager configuration, and selected workspace manifests before the dependency installation. After that step, copy the source needed for compilation. The exact commands vary with the package manager's workspace behavior, so verify what files it reads rather than assuming identical behavior across npm, pnpm, Yarn, and other tools.
Generated code creates a similar boundary problem. If dependency installation or compilation depends on a generated schema, the schema or the instructions producing it must be represented in the build graph. Moving an input later merely to improve the cache-hit rate can yield a stale artifact. Correctness is the constraint; reuse is the optimization.
For dependency graphs that vary by service, consider separate build targets or Bake configurations rather than one enormous shared context. Shared base stages are valuable when they express common immutable work, not when they force every service to consume unrelated files. A development team's biggest savings may come from reducing cross-service invalidation rather than adding another cache exporter.
Keep irrelevant files out of the context
A build context that includes local dependency directories, Git history, test reports, editor state, and compiled artifacts creates unnecessary transfer and invalidation opportunities. Define a .dockerignore file appropriate to the project:
.git
node_modules
dist
coverage
*.log
.env*
Inspect the exclusions rather than copying them blindly. A build that genuinely needs a generated directory or a template environment file must not accidentally exclude it. Treat secrets separately: including a real .env in the context is rarely an acceptable way to supply build credentials.
Docker's build best practices favor narrow contexts and multi-stage builds. BuildKit can avoid transferring some unused context data, but an explicit ignore file still provides a valuable constraint on what becomes available to the build.
For large source trees used only to compile an output, consider a read-only build-time bind mount instead of copying the source into an intermediate layer:
# syntax=docker/dockerfile:1
FROM golang:1.25 AS compile
WORKDIR /work
RUN --mount=type=bind,target=. \
go build -o /tmp/service ./cmd/service
This is an illustrative pattern, not a complete production image. Its generated binary goes outside the mounted source directory, because the bind mount is read-only by default. Docker's bind-mount guidance explains that mounted source is available during the instruction without being stored as a normal copied source layer. The mount's source still participates in cache validity; it is not a device for ignoring code changes.
Add cache mounts when expensive steps must rerun
Even with good layer ordering, changes to a lockfile will trigger installation. A package cache can reduce repeated network downloads in that situation.
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --no-audit --no-fund
COPY . .
RUN npm run build
The npm ci command still resolves dependencies according to the lockfile and performs the install. The mount provides npm's download cache, not a substitute for validation or a prepopulated node_modules tree. For other toolchains, choose the directory the tool actually uses: pip's cache, Go's module and compiler caches, Cargo's registry and build directories, or the appropriate .NET package directory.
Cache mount concurrency also matters. Docker's Dockerfile reference supports sharing=shared, sharing=private, and sharing=locked. Apt's database is an example where concurrent writers require care; Docker's examples use locked mounts:
RUN rm -f /etc/apt/apt.conf.d/docker-clean \
&& echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
> /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends gcc
A cache mount is an optimization, not a correctness dependency. Builders may reclaim its contents. A build should still succeed when the mount is empty, old, or partially populated, assuming upstream dependencies remain available and valid.
One easily missed limit is that BuildKit cache mount directories normally stay in the builder's local storage. Exporting layer cache with --cache-to does not package the contents of these mutable directories. If every CI job starts with a new builder, a cache mount might appear in the Dockerfile but be empty on every run.
Export build cache for disposable CI workers
Local reuse works well when the same builder persists. Hosted CI often discards the builder between jobs. In that environment, the major decision is where to place exported cache records and how much data the next worker must transfer to consume them.
Docker documents multiple cache backends, including inline, registry, local, and gha. Availability depends on the Buildx driver and, for the default Docker driver, whether the containerd image store is enabled. Do not assume a configuration valid for a dedicated docker-container builder will work unchanged with every Docker Engine installation.
A registry-backed cache can be imported by another worker:
docker buildx build \
--push \
-t ghcr.io/example/service:latest \
--cache-from type=registry,ref=ghcr.io/example/service:buildcache \
--cache-to type=registry,ref=ghcr.io/example/service:buildcache,mode=max \
.
This example assumes authentication to the registry and a compatible builder. The cache reference is separate from the final image tag. In Docker's registry backend documentation, mode=max can include intermediate-stage records, which is useful when the final runtime image contains only a subset of what was built.
An inline cache is simpler when the published image itself is the natural cache carrier. Docker's inline cache documentation also notes its limitations for more involved multi-stage build graphs. A registry cache is usually more flexible, but that flexibility can mean larger exports and more network traffic.
Do not use one mutable cache reference for unrelated images. For concurrent builds, plan branch-specific or workload-specific references so jobs do not overwrite one another's useful cache. Importing both a stable main-branch cache and a branch cache can provide a reasonable fallback when supported by the workflow.
Choose a cache namespace and trust boundary
An exported cache is a shared artifact with an owner, retention policy, and access rules. Identify who is allowed to write a cache reference and which builds are allowed to read it. A cache from a trusted main-branch workflow is different from a cache writable by arbitrary pull requests. Reusing cache across different trust levels can create a supply-chain risk, especially when the build consumes generated artifacts or unreviewed dependency sources.
Use names that distinguish applications, platforms, and trust contexts. For example, a service's stable CI cache might use one reference while feature branches publish to separate, disposable references. A branch workflow can try its own cache first and fall back to the protected branch's cache. Export rights should follow the same security boundaries as other build artifacts.
Architectures complicate apparent cache efficiency. If the target includes both linux/amd64 and linux/arm64, compilation outputs may differ even when source and Dockerfile instructions are identical. Record the platform when interpreting hits and misses. Cross-platform cache records can share certain operations, but a successful single-architecture experiment is not evidence that a multi-platform job will have the same result.
Handle GitHub Actions cache deliberately
GitHub Actions has a dedicated gha backend, which Docker currently describes as experimental. It can be convenient for workflows that already use the GitHub cache service, but it has access, retention, size, and scope constraints. Docker's GitHub Actions cache guide explains that the legacy cache-service v1 API stopped being supported on April 15, 2025; self-hosted workers running outdated Buildx or BuildKit components may need updates.
Its default scope is buildkit. Without distinct scopes, builds for different images can replace one another's cache entries. Event permissions also matter: a read-only workflow may import an existing cache but fail to export a new one. Cache writes from untrusted execution contexts should be limited deliberately rather than made universally writable just to make CI green.
The gha backend exports BuildKit layer cache; it does not automatically preserve cache-mount contents. Docker documents a separate extraction-and-injection workaround for those mutable directories. That additional mechanism is useful only when the cost of restoring the directories is justified by the work they save.
Calculate the cost of moving cache
A remote cache pays off only when the computation avoided is worth more than cache lookup, download, upload, and storage work. Think about the job in separate phases:
total build job time =
setup + cache import + work executed
+ image export/push + cache export + other CI overhead
This is a diagnostic decomposition, not a benchmark formula predicting an exact speedup. A build with small dependency layers and expensive compilation may benefit from a broad exported cache. A build producing huge intermediate artifacts over a slow network may not. A new worker that imports a cache but then builds a different platform may spend bandwidth on records it cannot use.
Evaluate both mode=min and mode=max when the backend supports them. The latter can preserve intermediate stages, yet the additional exports have a cost. Do not optimize the cache-hit count at the expense of elapsed job time.
Keep multi-stage builds efficient and secure
Multi-stage builds separate compilation from the runtime image. They can reduce the shipped image's surface area and allow BuildKit to skip stages that are not needed for a chosen target. But a multi-stage Dockerfile is not automatically fast. If a frequently changed source file is copied before installing dependencies in a builder stage, the same cache miss still occurs.
For a compiled service, separate dependency resolution, compilation, testing, and final assembly where those phases have different inputs. Choose --target when developing or testing a specific stage, and avoid rebuilding irrelevant outputs just because the Dockerfile contains them.
Security and freshness need their own rules. Docker's build-secret documentation warns against passing tokens through ARG or COPY; secret mounts and SSH mounts are designed for temporary access during a build. The bytes of a secret are not included in the build cache key. A changed token therefore does not automatically force a fresh fetch; explicitly invalidate the relevant step when the output must be refreshed.
Likewise, do not interpret a fully cached OS package installation as current with upstream security fixes. Decide how frequently base images are updated and how dependency vulnerabilities trigger rebuilds. Pinning a digest improves control over the selected base but does not replace the process for updating that pin.
Measure the whole pipeline, not the best cached step
An external cache can avoid a long compile but introduce significant upload and download work. A cache export that saves seconds of compilation while transferring large intermediate layers may make the job slower, especially across regions or slow links. The right measurement is end-to-end time including cache import, build execution, image push, and cache export.
Track a small number of operational indicators: elapsed job time by change type, which expensive steps hit cache, bytes transferred, cache storage growth, and the rate at which cache imports fail or miss. Compare equivalent workloads and builder configurations. Avoid drawing conclusions from one warm local build.
BuildKit also performs garbage collection. Builders with constrained disk can evict older layers and cache mounts, while aggressive manual pruning trades storage savings for future rebuild cost. The correct retention policy depends on how frequently each cache entry is reused.
A measured rollout sequence is straightforward. First remove irrelevant context inputs. Next isolate dependency manifests and the corresponding install steps. Then add package cache mounts where repeated installations remain costly. Finally, add remote cache export and import only when workers cannot reuse local state. After each change, test both cache-hit behavior and correctness after a forced rebuild.
Diagnose misses before adding another cache layer
When a step unexpectedly reruns, check the first step that lost its cache hit, not only the slow step at the bottom of the log. If npm ci reruns after a source-only edit, examine earlier COPY instructions and their context. If a whole build reruns after changing a label, build argument, platform, or base reference, trace where that input enters the build graph.
When cache imports appear to succeed but hit rates stay low, compare the builder driver, exported cache mode, image platform, scope/reference name, and the exact dependency inputs. A registry can store a valid cache manifest that simply does not match the new build. If using GitHub Actions, check permissions and scopes separately from Dockerfile behavior. If using cache mounts, remember that a remote layer cache import will not hydrate the mount directory.
Avoid using --no-cache as a routine fix for an inefficient Dockerfile. It is useful as a controlled measurement and freshness tool, but routinely disabling cache masks the structure that needs repair. Conversely, a cache hit should be intentionally invalidated when the desired output depends on a changing external input that is not otherwise represented in the cache key.
The useful result is a repeatable build that is faster for the changes teams make most often. BuildKit supplies the mechanisms; Dockerfile structure, CI persistence, refresh policy, and measurement determine whether those mechanisms deliver an actual improvement.