Docker BuildKit Cache Mounts: Cut CI Build Times in Half

Photo by CDEGlobal via Openverse (CC BY 2.0)
A cache mount is a directory declared with RUN --mount=type=cache,target=path that BuildKit persists across builds, separate from the normal image layer cache. Unlike a regular layer, its contents are never baked into the final image, so it's safe to use for large package manager caches like npm, pip, or Go module downloads without increasing image size.
Without a cache mount, changing package.json or requirements.txt invalidates every Docker layer after it, forcing a completely cold install that re-downloads every dependency. A cache mount ties the package manager's download cache to a persistent BuildKit-managed directory instead of the layer cache, so only genuinely new or changed packages get re-fetched.
No. GitHub-hosted runners start with an empty disk on every job, so a cache mount with no export configured behaves like a cold cache every single run. You need to set cache-from: type=gha and cache-to: type=gha,mode=max on docker/build-push-action to persist the cache in GitHub's Actions cache service between workflow runs.
Use the registry cache backend instead of the GitHub-specific gha backend. Run docker buildx build with --cache-to type=registry,ref=your-image:buildcache,mode=max and --cache-from pointed at the same tag. This works identically on GitLab CI, Bitbucket Pipelines, Jenkins, or any runner with registry push access, because it only depends on a container registry rather than a CI vendor's API.
mode=min, the default, only exports the layers that end up in your final image. mode=max exports every intermediate layer, including the content backing --mount=type=cache directories from earlier build stages. Cache mounts only actually persist across CI runs when you use mode=max — mode=min silently drops them.

Photo by CDEGlobal via Openverse (CC BY 2.0)
Most teams already know Docker layer caching exists, but layer caching alone doesn't help the moment you touch package.json, go.mod, or requirements.txt. The instant a dependency manifest changes, Docker invalidates every layer after the COPY step, and the very next RUN npm install starts from a completely empty cache, downloading every package again even though 95 percent of them didn't change. BuildKit cache mounts fix exactly this problem. They give a RUN instruction a persistent directory that survives across builds and across the layer cache invalidation that normally wipes everything out. This post is specifically about build speed, not image size. You'll still want a slim final stage and a small runtime image, but that's a separate concern from how fast the build itself runs, and it's covered in other posts on this site. Here we're only chasing wall-clock minutes off your CI pipeline.
BuildKit has been the default builder in Docker Engine and Docker Desktop for a while now, but it's worth confirming explicitly, especially on older CI images or self-hosted runners that might still default to the legacy builder. There are two ways to force it on: the DOCKER_BUILDKIT environment variable for classic docker build, or the newer docker buildx build command, which is BuildKit-native and is what Docker itself now recommends going forward since buildx also unlocks multi-platform builds and pluggable cache exporters.
Setting DOCKER_BUILDKIT=1 before a normal docker build call is the fastest way to get BuildKit behavior in an existing pipeline without changing any commands. docker buildx build is the longer-term direction: it's a CLI plugin that talks to a BuildKit daemon directly, supports the --cache-to and --cache-from flags used later in this post, and is already installed by default on recent Docker Desktop and Docker Engine versions. If your CI image is old enough that buildx isn't available, DOCKER_BUILDKIT=1 docker build is a safe fallback that still respects --mount=type=cache in your Dockerfile.
# Enable BuildKit for a one-off classic build
DOCKER_BUILDKIT=1 docker build -t myapp:latest .
# Or use buildx (BuildKit-native, ships with modern Docker Desktop / Engine)
docker buildx build -t myapp:latest .
# Persist it so you never have to think about it again
echo 'export DOCKER_BUILDKIT=1' >> ~/.zshrcCache mounts are declared per RUN instruction using the --mount=type=cache flag, and they behave completely differently from a normal COPY-based layer. Instead of being baked into the image, the mounted directory is backed by storage that BuildKit manages on the host or CI runner and reuses across builds, regardless of whether the layers above it changed. That's the key property that makes this useful for package managers specifically: a package manager's download cache doesn't need to be invalidated just because your source code changed, only when the actual package versions change, so tying it to Docker's layer cache was always the wrong lifetime for it.
The core syntax is RUN --mount=type=cache,target=path, placed directly on the RUN instruction that needs the persistent directory. The target is the only required option; everything else — id, sharing, uid, gid, mode — has a sensible default. For a Node project, mounting BuildKit's cache onto npm's own cache directory means npm ci only has to fetch packages it hasn't seen before, even after a full package-lock.json change, because the downloaded tarballs are still sitting in the mount from the last build. Adding sharing=locked is worth doing for any cache mount that might run concurrently — for example if two build stages or two parallel CI jobs touch the same cache id — so BuildKit serializes access instead of letting two writers corrupt the same directory at once.
# syntax=docker/dockerfile:1
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# Cache mount: /root/.npm survives across builds even when package.json
# changes and busts the layer cache above it. "target" is required;
# "sharing=locked" serializes access so parallel builds don't corrupt
# the shared cache directory.
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm ci
FROM node:20-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm run build
FROM node:20-slim AS runner
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/main.js"]Cache mount content never gets baked into the final image layers, so it's safe to point --mount=type=cache at large directories like node_modules caches or Go's module cache without worrying about image size. It only exists during the RUN step and is stored by BuildKit outside the image itself.
The exact same pattern applies to any package manager with its own on-disk cache. For apt-based images, mounting /var/cache/apt and /var/lib/apt means apt-get install doesn't re-download .deb packages that were already fetched in a previous build — just remember these two directories are normally cleared by apt's own cleanup scripts, so this pattern assumes you're not running apt-get clean afterward inside the same RUN. For Python, mounting pip's cache directory means pip install -r requirements.txt reuses previously built wheels instead of rebuilding them from source, which matters a lot for packages with native extensions. For Go, there are two separate caches worth mounting: the module download cache at /go/pkg/mod, and Go's own compiler build cache at /root/.cache/go-build, which caches compiled packages between builds even when only one file in your project changed.
# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
# Go's own build cache (distinct from the module cache above)
RUN --mount=type=cache,target=/root/.cache/go-build \
go build -o /out/server .
FROM debian:bookworm-slim
COPY --from=build /out/server /usr/local/bin/server
CMD ["server"]
# --- apt example: keep downloaded .deb files across builds ---
# 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 curl
# --- pip example ---
# RUN --mount=type=cache,target=/root/.cache/pip \
# pip install -r requirements.txtEverything above works immediately on a local developer machine, because the BuildKit cache lives on that machine's disk and survives between docker build invocations automatically. CI runners break this assumption. GitHub-hosted runners, GitLab shared runners, and most ephemeral CI infrastructure spin up a brand new virtual machine for every single job, with an empty disk and no BuildKit cache from any previous run. Without extra configuration, every CI build effectively behaves like the very first build you ever ran locally — cold, with nothing cached, --mount=type=cache included. The fix is to explicitly export the BuildKit cache somewhere that does persist between CI runs, and import it back in on the next run.
A cache mount without a working cache export in CI is a silent no-op. Your Dockerfile will build correctly and nobody will get an error, it will just never actually get faster, because there's no persistent cache backend behind the mount on an ephemeral runner. Always verify the export step actually ran by checking the CI job logs for the cache upload step, not just by assuming --mount=type=cache handles it automatically.
Docker's build-push-action integrates directly with the GitHub Actions cache service through a dedicated gha cache backend. Setting cache-from: type=gha and cache-to: type=gha,mode=max on the action tells BuildKit to pull the previous run's cache from GitHub's cache API before building, and push the updated cache back afterward. The mode=max setting matters: the default mode=min only caches the layers that end up in your final image, while mode=max caches every intermediate layer, including the contents backing your --mount=type=cache directories, which is exactly what you need for the npm, apt, or Go patterns above to actually carry over between workflow runs.
# .github/workflows/build.yml
name: build
on: [push]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/my-org/myapp:latest
# GitHub Actions cache backend — persists the BuildKit cache
# (including our --mount=type=cache layers) between workflow runs
cache-from: type=gha
cache-to: type=gha,mode=maxGitLab CI runners are just as ephemeral as GitHub-hosted ones, and there's no GitLab-native equivalent to the gha backend built into buildx. The portable option that works identically on GitLab, Bitbucket Pipelines, Jenkins, or any other CI system is the registry cache backend: BuildKit pushes the build cache as its own manifest to a container registry you already have push access to, tagged separately from your actual application image, then pulls it back down on the next run with --cache-from. Because this only depends on registry access rather than a specific CI vendor's API, it's the backend to reach for whenever you're not specifically on GitHub Actions.
# Registry-backed cache — portable across any CI runner, not just GitHub Actions
docker buildx build \
--push \
-t registry.example.com/myapp:latest \
--cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache \
.
# mode=max caches every intermediate layer, not just the final image —
# this is what lets --mount=type=cache content survive between CI runners
# that each start with a cold, empty local disk.Keep the cache image on a completely separate tag, like myapp:buildcache, rather than reusing your release tags. Cache manifests aren't meant to be run as containers and mixing them into your release tag history makes your registry harder to audit.
If your pipeline runs exclusively on GitHub Actions, the gha backend is simpler to wire up because build-push-action handles the authentication for you automatically — there's no separate registry push step to configure or credentials to manage beyond what the workflow already has. The registry backend needs push access configured explicitly, but it travels with you if you ever move CI providers, and it works identically whether you're on GitLab CI, a self-hosted Jenkins runner, or a local reproduction of the CI environment. Teams running multi-cloud or multi-vendor CI setups tend to standardize on the registry backend for exactly that portability, even when part of their pipeline happens to run on GitHub Actions.
In a GitLab CI job, the registry cache pattern looks the same as any other environment: run docker buildx build with --cache-to type=registry,ref matching your cache tag and --cache-from pointed at the same tag, inside a job that already has docker login credentials for that registry. No GitLab-specific plugin or extra service is required beyond Docker-in-Docker or a Kaniko-style executor that supports buildx.
The realistic way to validate any of this is to time two consecutive builds of the same commit in your actual CI environment: one with cache export and import wired up, one without. For a typical Node or Python service with a moderate dependency tree, teams commonly see dependency-install steps drop from multiple minutes to under thirty seconds once the cache backend is actually working, because the install step becomes a cache lookup instead of a network-bound download. The gain scales with how large your dependency tree is and how often it changes relative to your application code — a monorepo with a huge node_modules tree and infrequent lockfile changes benefits the most.