If your Docker builds take several minutes in every CI run, downloading and restoring dependencies may be one reason. A .NET application may repeatedly restore NuGet packages, a Node.js project may download npm dependencies, and a Python application may reinstall packages even when most dependencies have not changed.
These operations waste time when multiple builds need the same dependencies.
Docker BuildKit provides a feature called cache mounts that allows build commands to reuse cached files between builds when the cache is available. Instead of downloading every dependency again, tools can reuse previously downloaded packages and fetch only what is missing.
Cache mounts are especially useful in continuous integration pipelines, where dependency restoration can take a significant part of the build time.
In this article, you will learn how BuildKit cache mounts work, how to use them with .NET and Node.js, how to configure them in GitHub Actions, and how to avoid common caching mistakes.
What Are Docker BuildKit Cache Mounts?
Docker BuildKit is the build engine used by modern Docker builds. It supports several caching features that can reduce unnecessary work during image creation.
A cache mount lets a build command access a directory whose contents can be preserved and reused by later builds.
For example, a .NET build can use a cache mount for its NuGet package directory. When the build runs again, NuGet may reuse packages already present in the cache instead of downloading them all over again.
The basic idea looks like this:
First Build
|
v
Download Dependencies
|
v
Store Packages in Cache Mount
|
v
Build Application
Later Build
|
v
Reuse Available Cached Packages
|
v
Download Missing Packages
|
v
Build Application
The cache mount does not replace Docker's normal layer cache. It complements it.
Docker's layer cache can skip an entire build instruction when its inputs have not changed. A cache mount is useful when a build instruction must run again but can still benefit from files downloaded during previous runs.
How Cache Mounts Differ from Docker Layer Caching
Docker layer caching and cache mounts solve related but different problems.
Consider a Dockerfile instruction that restores application dependencies. If the dependency files have not changed and the corresponding layer remains available, Docker may reuse the entire layer without executing the restore command.
But if a source file or another input invalidates a later layer, Docker may need to execute some instructions again.
A cache mount can still provide previously downloaded packages when the restore command runs again.
| Feature | Docker Layer Cache | BuildKit Cache Mount |
|---|
| Main purpose | Reuse completed build steps | Reuse files used by build commands |
| Can skip an unchanged instruction | Yes | No, not by itself |
| Useful when a build command must run again | Depends on layer validity | Yes |
| Typical contents | Completed layer filesystem | Downloaded packages, compiler caches, tool caches |
| Requires persistent availability | The layer cache must be available | The mount cache must be available |
| Can be used together | Yes | Yes |
For the best results, use both features. Keep Dockerfile instructions arranged to preserve reusable layers, and use cache mounts for expensive dependency downloads or compilation work.
Step 1: Enable BuildKit
BuildKit is available in modern Docker environments. You can explicitly enable BuildKit when building an image:
DOCKER_BUILDKIT=1 docker build -t myapp:latest .
If your Docker environment already uses BuildKit by default, this setting may not be necessary.
For newer Docker workflows, you can also use docker buildx build, which provides BuildKit-based builds and additional cache export options.
Step 2: Use a Cache Mount for .NET NuGet Packages
Let's start with an ASP.NET Core application.
During dotnet restore, NuGet downloads packages and stores them in its package cache. By default, the cache is commonly located under the user's NuGet package directory.
You can mount that directory as a BuildKit cache.
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY *.sln ./
COPY MyApp/*.csproj ./MyApp/
RUN --mount=type=cache,id=nuget-packages,target=/root/.nuget/packages \
dotnet restore ./MyApp/MyApp.csproj
COPY . .
RUN --mount=type=cache,id=nuget-packages,target=/root/.nuget/packages \
dotnet publish ./MyApp/MyApp.csproj \
--configuration Release \
--no-restore \
--output /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
Replace MyApp and MyApp.dll with the actual project and assembly names.
How This Dockerfile Works
The first stage uses the .NET SDK to restore and publish the application. The final stage contains the ASP.NET Core runtime and the published application files.
The important part is the cache mount:
RUN --mount=type=cache,id=nuget-packages,target=/root/.nuget/packages \
dotnet restore ./MyApp/MyApp.csproj
BuildKit mounts a cache directory at the NuGet package location for the duration of that build instruction.
The id=nuget-packages value identifies the cache. Using the same ID for the restore and publish instructions allows them to use the same cache in the same builder environment.
The mount is temporary from the perspective of the image filesystem. Its contents are not automatically included in the final image layer. This helps keep the published image focused on the application rather than the build cache.
The example copies the solution and project files before restoring dependencies. This helps Docker reuse the restore layer when application source files change but dependency definitions remain the same.
For solutions with multiple projects, copy all project files needed by the restore operation before running dotnet restore. Otherwise, the restore may fail or the dependency layer may be invalidated more often than necessary.
Step 3: Use Cache Mounts with Node.js
Node.js applications can also benefit from BuildKit cache mounts.
For example, npm stores downloaded package data in its local cache directory. You can mount that directory during dependency installation.
# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
The cache mount preserves npm's downloaded package data for later builds when the same BuildKit cache remains available.
The npm ci command still installs the dependencies specified by the lockfile. It does not simply skip installation because a cache exists. Instead, npm can reuse cached downloads when possible.
This is useful in CI pipelines where the dependency installation step frequently runs again.
For production applications, use a supported Node.js version and follow the project's established version policy.
Step 4: Use Cache Mounts for Python Packages
Python builds can use the same approach for pip's download cache.
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --prefix=/install -r requirements.txt
COPY . .
FROM python:3.12-slim AS final
WORKDIR /app
COPY --from=build /install /usr/local
COPY . .
CMD ["python", "app.py"]
This example installs dependencies into a separate directory and copies them into the final stage.
The cache mount allows pip to reuse downloaded packages when available. It does not guarantee that every dependency installation can be skipped.
Native Python packages may require compilers or operating system libraries during installation. Make sure those build requirements are available in the build stage, and include the necessary runtime libraries in the final image.
Step 5: Make BuildKit Caches Available in GitHub Actions
Cache mounts are most useful when their contents survive between builds.
On a persistent self-hosted runner, BuildKit's cache may remain available across jobs, depending on how the builder is configured.
On GitHub-hosted runners, each job usually starts with a fresh machine. If you create a new builder for every job, its internal cache mounts may not survive the job.
This means a cache mount alone does not guarantee faster builds across separate GitHub-hosted runner jobs.
You need a caching strategy that preserves or restores the BuildKit cache between runs.
One option is to use BuildKit's external cache support through the GitHub Actions cache backend. Another is to use a registry-backed cache when that fits your workflow.
A typical docker/build-push-action configuration looks like this:
name: Build Container
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
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
This example uses the GitHub Actions cache backend to export and import BuildKit build cache.
The mode=max setting can preserve cache for intermediate build stages as well as the final image, which is useful for multi-stage Dockerfiles.
However, external build-cache export and cache mounts are not exactly the same thing. Do not assume that every cache mount's mutable contents will be preserved by an external layer cache automatically.
If your workflow depends specifically on retaining cache-mount contents across fresh runners, verify the behavior supported by your BuildKit version and cache backend. BuildKit supports additional cache-mount persistence approaches, including using an external cache directory with the appropriate build configuration.
For simpler pipelines, the external layer cache may already provide a significant improvement by skipping unchanged dependency installation layers.
Step 6: Measure Whether the Cache Helps
Do not assume a cache mount has improved your build simply because the Dockerfile contains one.
Compare build times before and after introducing the cache.
For example, record:
- Total build duration.
- Dependency restore duration.
- Cache hit or reuse behavior.
- Time spent downloading packages.
- Cache size and storage overhead.
A first build may still be slow because the cache is empty. Later builds can improve as the cache becomes populated.
Changes to lockfiles, project files, base images, package versions, or build configuration may also require additional downloads or invalidate relevant layers.
Run several builds with representative changes to understand the real benefit.
Common Cache Mount Mistakes to Avoid
1. Assuming the Cache Always Survives CI Jobs
A cache mount is tied to the BuildKit builder's cache storage. If that storage disappears after a job, the next build may start with an empty cache.
Choose a persistent builder or a suitable external cache strategy.
2. Mounting the Wrong Directory
The cache mount must match the package manager's actual cache location.
For example, mounting /root/.npm will not help if npm runs under another user and stores its cache elsewhere.
Check the environment used by the build command.
3. Forgetting to Copy Dependency Files First
Copying all source files before dependency restoration can cause unnecessary layer invalidation.
Copy project files and lockfiles first, restore dependencies, and then copy the remaining source files when the application structure allows it.
4. Expecting Cache Mounts to Skip Installation Commands
A cache mount reuses cached files; it does not automatically skip dotnet restore, npm ci, or pip install.
Those commands still need to validate and install the required dependencies.
5. Using an Unbounded Cache Strategy
Caches consume storage and can become expensive to maintain. Review cache sizes, cleanup policies, and the number of separate cache IDs used by your builds.
6. Sharing Incompatible Cache Contents
Use appropriate cache IDs and isolation for different package managers, projects, or build environments.
Do not assume that caches can be safely shared across incompatible tool versions or environments.
Best Practices for Faster Docker Builds
- Combine layer caching with cache mounts: Each feature reduces different kinds of repeated work.
- Copy lockfiles before application source: Preserve dependency layers when source code changes.
- Use multi-stage builds: Keep build tools and unnecessary dependencies out of the final image.
- Choose stable cache IDs: Reuse the intended cache across compatible build steps.
- Plan for ephemeral CI runners: Configure persistent or external caching when necessary.
- Measure before and after: Use actual build timings rather than assumptions.
- Keep dependencies reproducible: Use lockfiles and controlled package versions so caching does not replace dependency management.
Conclusion
Docker BuildKit cache mounts can reduce CI build times by reusing downloaded packages and other build files. They are particularly useful for projects that repeatedly restore NuGet, npm, or Python dependencies.
The best results come from combining cache mounts with well-structured Docker layers and an external cache strategy that fits your CI environment.
Remember that a cache mount is an optimization, not a guarantee that a build command will be skipped or that cache contents will survive every runner restart. Configure the cache deliberately, measure its impact, and keep dependency management reproducible.
Join the conversation! Your thoughts help the community grow.