Docker builds can become slow in continuous integration pipelines when every run downloads the same dependencies, restores packages, or recompiles unchanged components. Even when Docker reuses image layers, some build steps may still repeat work because their cache keys change or the CI runner starts with an empty local cache.
Docker BuildKit provides cache mounts that let build commands reuse selected files between builds. For example, a .NET build can reuse downloaded NuGet packages, while a Node.js build can reuse npm's package cache.
Unlike ordinary Docker layer caching, cache mounts preserve selected directories for reuse by later executions of build steps. This can reduce repeated downloads and package restoration without including the cache contents in the final container image.
In this article, we will look at how BuildKit cache mounts work, how to use them in Dockerfiles, and how to preserve useful caches across GitHub Actions CI runs.
What Is Docker BuildKit?
BuildKit is Docker's modern build engine. It supports features such as parallel build execution, improved cache handling, secret mounts, and cache mounts.
A traditional Docker build can reuse a layer when the instruction and its relevant inputs match a cached result. For example, if a dependency manifest has not changed, Docker may reuse the layer created by the package restoration step.
However, when a layer must be rebuilt, the command inside it may download dependencies again. This can happen when source files change, the build runs on a fresh machine, or the CI environment does not retain Docker's previous cache.
BuildKit cache mounts address this issue by keeping selected directories available to build commands across executions. Even if a build step must run again, it may reuse the files stored in its cache mount.
For example, a .NET build may restore packages into a NuGet cache directory. A cache mount can preserve downloaded packages so a later restore does not need to download every package again.
Docker Layer Cache vs. BuildKit Cache Mounts
These mechanisms solve related but different problems.
Feature | Docker layer cache | BuildKit cache mount |
|---|
What is reused | Results of completed build instructions | Files stored in a mounted cache directory |
Reuse when a build step changes | Usually the layer must be rebuilt | The command can still reuse cached files |
Typical use | Reuse unchanged build and application layers | Reuse downloaded packages and compiler caches |
Included in the final image | Only when part of the resulting image filesystem | Cache contents are not committed to the image by the mount itself |
Useful for fresh CI runners | Only if the layer cache is restored | Only if the relevant BuildKit cache is available or restored |
The two approaches work well together. Layer caching can skip entire steps, while cache mounts can speed up steps that still need to execute.
A cache mount is not automatically a permanent cache shared by every machine. Its contents belong to BuildKit's cache storage and can disappear when the cache is pruned or the build environment is replaced. CI pipelines must preserve or restore the relevant BuildKit cache if they want reuse across fresh runners.
Step 1: Enable BuildKit
Recent Docker versions use BuildKit by default in many environments. You can also explicitly enable it for a build:
DOCKER_BUILDKIT=1 docker build -t myapp:local .
When using Docker Buildx, you can run:
docker buildx build -t myapp:local .
Buildx uses BuildKit and provides additional control over builders, cache export, cache import, and multi-platform builds.
If you use GitHub Actions, you can configure Buildx in the workflow with the official Docker setup action. This makes the build environment explicit and allows you to configure caching consistently.
Step 2: Add a Cache Mount to a .NET Dockerfile
.NET projects commonly restore packages from NuGet. On a clean build environment, downloading all dependencies can consume a significant amount of time, particularly when the project has many packages.
The following Dockerfile uses a BuildKit cache mount for NuGet packages.
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY MyApp.csproj .
RUN --mount=type=cache,id=myapp-nuget,target=/root/.nuget/packages \
dotnet restore MyApp.csproj
COPY . .
RUN --mount=type=cache,id=myapp-nuget,target=/root/.nuget/packages \
dotnet publish MyApp.csproj \
-c Release \
-o /app/publish \
--no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
This example assumes the project targets a compatible .NET version and uses MyApp.csproj as its project file. Adjust the project name, framework, and base image to match your application.
The first line selects a Dockerfile frontend that supports the cache mount syntax.
The important instruction is:
RUN --mount=type=cache,id=myapp-nuget,target=/root/.nuget/packages \
dotnet restore MyApp.csproj
The target identifies the directory where the cache is mounted during the build command. The id gives the cache a stable name so the restore and publish steps can use the same cache.
The cache mount is temporary from the image filesystem's perspective. Its contents are not copied into the resulting image merely because the command used the mount.
The publish step uses the same cache mount and --no-restore because the earlier step already restored the dependencies. The project file is copied before the restore command so source-code changes do not automatically invalidate the dependency-restoration layer when the project file remains unchanged.
For multi-project solutions, copy the required project files and restore the appropriate solution or project. A single-project example may need adjustments for project references and build configuration.
Step 3: Cache npm Packages in a Node.js Build
The same technique works for Node.js applications.
For example, an npm project can use a cache mount for npm's download cache:
# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,id=myapp-npm,target=/root/.npm \
npm ci --cache /root/.npm
COPY . .
RUN npm run build
This example assumes the project uses npm and has a valid lock file. Adjust the Node.js version and build commands to match the project.
The cache directory stores downloaded package data used by npm. The lock file remains important because it defines the dependency versions to install.
The cache mount does not replace package-lock.json or make dependency resolution reproducible on its own. It is a performance optimization, not a substitute for dependency management.
If your application uses a different package manager, use that tool's supported cache directory and follow its caching requirements.
Step 4: Preserve BuildKit Cache Across GitHub Actions Runs
Cache mounts are particularly useful when a BuildKit builder is reused. However, GitHub-hosted runners are generally temporary, so the builder and its local cache may disappear after a workflow finishes.
To retain cache data between workflow runs, configure BuildKit's external cache import and export. GitHub Actions provides a cache backend for BuildKit when the build action and runner are configured appropriately.
The following workflow illustrates the setup:
name: Build Container
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out source code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image with cache
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: myapp:ci
cache-from: type=gha,scope=myapp
cache-to: type=gha,mode=max,scope=myapp
The cache-from setting imports previously exported BuildKit cache data. The cache-to setting exports cache data for later workflow runs.
The scope separates this build's cache from caches used by other images or workflows. Choose a scope that reflects the project and build configuration.
The mode=max option exports more intermediate build cache, including eligible intermediate stages. This can improve cache reuse for multi-stage builds, but it can also increase cache size and transfer time.
For this workflow, the build action handles the BuildKit cache export and import. The cache mount in the Dockerfile can then benefit when its contents are available in the restored builder cache.
External cache behavior depends on the builder, cache backend, cache availability, and Docker configuration. Validate that your selected setup preserves the cache-mount data you need rather than assuming that every cache directory is retained indefinitely.
For production workflows, review action versions and consider pinning third-party actions to verified commit SHAs according to your security policy.
Step 5: Keep Dependency Layers Stable
Cache mounts are most effective when the Dockerfile is structured to avoid unnecessary dependency restoration.
For example, this sequence is generally preferable:
WORKDIR /src
COPY MyApp.csproj .
RUN --mount=type=cache,id=myapp-nuget,target=/root/.nuget/packages \
dotnet restore MyApp.csproj
COPY . .
RUN dotnet publish MyApp.csproj \
-c Release \
-o /app/publish \
--no-restore
The project file is copied before application source files. If only a source file changes, Docker may reuse the dependency-restoration layer because its inputs have not changed.
If the project file or dependency declarations change, the restore step needs to run again. The cache mount can still help by reusing packages already downloaded into the cache.
For Node.js, copying package.json and package-lock.json before the source files serves the same purpose.
Also maintain a .dockerignore file so unnecessary files, local build outputs, and other unrelated content are not sent to the build context.
Step 6: Understand Cache Invalidation and Isolation
Caching improves performance, but it must not change the correctness of the build.
A package cache should be treated as reusable data, not as the authoritative record of which dependencies belong in the application. Lock files, package manifests, and normal build validation should continue to determine the build inputs.
Cache IDs also need sensible isolation. Projects with different tool versions, package ecosystems, or incompatible build configurations may need separate cache IDs.
For example, avoid sharing one cache ID indiscriminately across unrelated projects simply because they use the same package manager.
Be careful when builds run concurrently. BuildKit manages cache mounts, but your build commands and tools must still be compatible with the cache's sharing behavior. If a package manager cannot safely use a shared cache concurrently, configure an appropriate sharing mode or isolate the cache.
Do not store credentials, private keys, or other secrets in cache directories. Build caches may be exported or retained beyond the lifetime of the runner, and their access controls must be considered.
Step 7: Measure Whether the Cache Helps
A cache mount does not guarantee that every build will become faster. Its value depends on how much work can be reused and how much time is spent restoring, downloading, compiling, and transferring cache data.
Measure representative builds before and after enabling caching.
Track:
Total CI build duration.
Dependency restore duration.
Cache import and export time.
Cache hit rates where available.
Image size.
Build failures and cache-related errors.
For example, a project that spends most of its build time compiling source code may see limited improvement from caching downloaded packages. A project that repeatedly downloads large dependency sets may benefit much more.
The first run after introducing a cache may be slower because it must populate the cache. Compare subsequent runs under similar conditions rather than judging the optimization from a single build.
Common Mistakes to Avoid
Using a cache mount without preserving or restoring the BuildKit cache across temporary CI runners.
Assuming a cache mount is the same as a Docker layer cache.
Replacing lock files with cached package contents as the source of dependency truth.
Copying the entire source tree before dependency restoration when a more stable layer structure is possible.
Sharing cache IDs across unrelated projects without considering compatibility.
Caching secrets or credentials in directories that may be exported.
Assuming every cached build is faster without measuring import, export, and restore time.
Using a mutable base-image tag without considering how image changes affect build reproducibility.
Best Practices for Docker BuildKit Cache Mounts
Use cache mounts for package managers and build tools that support reusable local caches.
Keep dependency manifests and lock files separate from frequently changing source files in the Dockerfile.
Configure external cache import and export when using temporary CI runners.
Use distinct cache scopes for independent projects and build configurations.
Keep secrets out of build caches and review cache access permissions.
Measure build duration and cache behavior before changing more settings.
Keep the final runtime image free of unnecessary SDKs, build tools, and cached package data.
Validate cache behavior after changing the Dockerfile, builder, or CI environment.
Summary
Docker BuildKit cache mounts can reduce repeated dependency downloads and other build work by preserving selected directories across build executions. They complement ordinary Docker layer caching: layer caching reuses completed build steps, while cache mounts can help a step run faster when it must execute again.
For .NET applications, a NuGet cache mount can reuse downloaded packages. For Node.js projects, an npm cache mount can reuse package data. In CI, external BuildKit caching can help preserve useful cache information across temporary GitHub Actions runners.
The best results come from combining cache mounts with stable Dockerfile structure, lock files, appropriate cache isolation, and measurements of real build performance. Caching should make builds faster without changing which dependencies are installed or which artifact is deployed.
Join the conversation! Your thoughts help the community grow.