Docker BuildKit Cache Mounts and Faster CI Builds
AI generated
Docker BuildKit · CI/CD · Performance · Cache Mounts
BuildKit Cache Mounts
and Faster CI Builds with Docker

CI builds that take five minutes because every run downloads all Composer packages and npm modules from scratch are not a law of nature. Docker BuildKit cache mounts keep package caches persistent between builds without baking them into the image. This article shows how RUN --mount=type=cache works and how layer strategy, parallel stages and registry cache combine to cut CI runtimes drastically.

14 min read BuildKit · Cache Mounts · Layer Strategy · Registry Cache · Multi-Stage Docker 24+ · BuildKit 0.12+ · GitHub Actions · GitLab CI

1. The Real Problem Behind Long CI Builds

Long CI build times are rarely a resource problem, they are a cache problem. The typical flow: a developer pushes a commit, the pipeline spins up a fresh runner, the Docker build starts and installs the same 200 Composer packages and 500 npm dependencies from scratch on every run. The download takes minutes, compiling native extensions takes several minutes more. The result is five to eight minutes of waiting for a commit that changed three lines of PHP.

The classic counter-concept, Docker layer caching, only helps to a limited extent. Layer cache only kicks in when a layer is exactly identical to the previous build. If composer.json changes even in the version constraint of a single package, the layer becomes invalid and every layer built on top of it is rebuilt. That is correct for the image content, but inefficient for the download process: packages that did not change get downloaded again simply because the layer hash no longer matches. BuildKit cache mounts solve this problem by separating the package manager cache from the image layer.

The result of consistently applying BuildKit cache mounts together with an optimized layer order and registry cache is measurable: CI build times for PHP projects using Composer drop from five minutes to under one minute for normal feature branches. npm builds with an extensive frontend build process drop from seven to two minutes. These numbers do not depend on the infrastructure, they depend on the right cache strategy in the Dockerfile.

2. BuildKit: What Changes Compared to the Old Builder

Docker BuildKit has been the default builder since Docker 23.0 and fully replaces the old legacy builder. The relevant differences for CI build performance: BuildKit actually runs parallel stages in parallel instead of sequentially; it supports the RUN --mount syntax for cache mounts, secrets and SSH forwarding; and it offers more granular cache export options for registry-based cache. The old builder has none of these features and is no longer developed further in new Docker versions.

For CI systems that still need to explicitly enable BuildKit: the environment variable DOCKER_BUILDKIT=1 turns on BuildKit for all Docker build invocations. In GitHub Actions, BuildKit has been active by default since version 3 of the actions/docker-build-push-action. In GitLab CI, the Docker-in-Docker service must be configured with the variable DOCKER_BUILDKIT=1. The --progress=plain flag on the build outputs detailed cache hit/miss information and helps when debugging the cache mount configuration.

An important difference for CI: BuildKit cache mounts are local by design, they store the cache in the local BuildKit daemon of the builder. On ephemeral CI runners that get recreated after every job, a local cache is useless. That is what the registry cache export (--cache-to type=registry) is for: it writes the build cache to a container registry, and it can be imported by the same or a different runner on the next run. This combination of cache mounts and registry cache is the foundation for consistently fast CI builds without a persistent runner disk.


# Enable BuildKit explicitly for older Docker versions
export DOCKER_BUILDKIT=1

# Build with cache export to registry (for ephemeral CI runners)
docker buildx build \
  --cache-from type=registry,ref=registry.example.com/app:buildcache \
  --cache-to   type=registry,ref=registry.example.com/app:buildcache,mode=max \
  --tag registry.example.com/app:${CI_COMMIT_SHA} \
  --push \
  .

# Inspect cache hit/miss ratio with verbose progress output
docker buildx build \
  --progress=plain \
  --cache-from type=registry,ref=registry.example.com/app:buildcache \
  . 2>&1 | grep -E "(CACHED|cache miss|#[0-9]+ )"

# GitHub Actions: enable BuildKit and use buildx
# .github/workflows/build.yml
# - uses: docker/setup-buildx-action@v3
# - uses: docker/build-push-action@v5
#   with:
#     cache-from: type=gha
#     cache-to: type=gha,mode=max

3. RUN --mount=type=cache: Mechanism and Semantics

RUN --mount=type=cache mounts a persistent directory during the build step that survives between builds but never becomes part of the resulting image layer. This is the crucial difference to normal layer caching: layer cache only makes an entire build step redundant when both the input and the command are unchanged. Cache mounts, on the other hand, stay useful even when the command has changed, the package manager finds its cache in place, only downloads packages that changed, and is therefore faster even though the result is a new layer.

The id option of a cache mount names the cache scope. Different Dockerfiles or services can share the same cache ID and thus reuse the same package manager cache. This is useful in monorepos with several services that draw from the same package pool. The sharing mode determines what happens when several parallel builds use the same cache mount: shared allows concurrent read access (default), private creates a copy per build, and locked serializes access. For package manager caches, shared is the right choice because most package managers write to their cache in a thread-safe way.

4. Keeping the Composer Cache Persistent

Composer stores downloaded packages in a local cache directory that gets reused on the next install. Without a BuildKit cache mount, this directory is discarded with every build layer, so Composer has to re-download the packages every single time. With RUN --mount=type=cache,id=composer,target=/root/.composer/cache, the Composer cache survives between builds. The result: only changed or new packages get downloaded, every unchanged package is served from the cache mount.

Important detail: the --mount flag as part of the RUN instruction must sit directly in front of the Composer call, not on a separate line. Also, the composer install line must not run inside a script that cannot see the cache mount path. A common mistake is running Composer as a different user than the one whose home directory is configured as the cache mount, in which case Composer writes to a different directory and the cache stays empty. Combining COMPOSER_CACHE_DIR=/composer-cache with an explicit mount path outside of /root avoids this problem.


# Dockerfile: PHP application with BuildKit cache mounts for Composer
# syntax=docker/dockerfile:1.6
FROM php:8.4-fpm-alpine AS vendor

# Install build dependencies without polluting the layer with APT cache
RUN --mount=type=cache,id=apk-cache,target=/var/cache/apk \
    apk add --no-cache $PHPIZE_DEPS libzip-dev \
    && docker-php-ext-install zip pdo_mysql opcache

# Install Composer itself
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /app

# Copy dependency manifests first, enables layer cache when no deps change
COPY composer.json composer.lock ./

# Mount Composer cache, persists between builds, NOT included in image layer
RUN --mount=type=cache,id=composer-cache,target=/root/.composer/cache \
    composer install \
      --no-dev \
      --no-interaction \
      --optimize-autoloader \
      --no-scripts \
      --prefer-dist

# Copy application source, separate layer so source changes don't bust dep layer
COPY src/ ./src/

# Run post-install scripts after source is available
RUN composer dump-autoload --optimize --no-dev

# ---- Final image: only runtime files, no build tools ----
FROM php:8.4-fpm-alpine AS runtime
COPY --from=vendor /app /app
WORKDIR /app

5. Using the npm and yarn Cache Efficiently

npm and yarn behave similarly to Composer: they have a local cache directory (~/.npm or /root/.yarn/berry/cache respectively) that holds downloaded packages. With BuildKit cache mounts, this directory can be kept persistent between builds. The difference compared to Composer is that npm builds are often coupled with frontend build processes (Tailwind CSS, Webpack, Vite) which use their own aggressive caches. A cache mount for npm packages and a separate cache mount for the build tool cache can be combined.

An important detail about npm ci versus npm install: npm ci deletes the node_modules directory before installing, but still uses the ~/.npm cache for downloaded tarballs. That is why the cache mount on ~/.npm is effective with npm ci too and brings measurable time savings. For Yarn Berry (v3+), the cache lives under /root/.yarn/berry/cache and works the same way. The cache ID should carry the package manager name to avoid collisions when a monorepo uses both npm and Composer.

6. Installing APT Packages Without Layer Bloat

APT packages in Debian- and Ubuntu-based images are a classic source of image bloat and long build times. The traditional pattern apt-get update && apt-get install -y ... && rm -rf /var/lib/apt/lists/* deletes the APT cache after installation to save on layer size. With BuildKit cache mounts, this trade-off disappears: the APT cache is mounted as a cache mount, stays available between builds (faster package installs), and still never ends up in the final image layer.

The pattern: RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt --mount=type=cache,id=apt-lib,target=/var/lib/apt mounts both APT directories as cache mounts. apt-get update fills the cache, apt-get install installs from the cache, and on the next build apt-get update is only needed once the cache has become invalid. Deleting the cache at the end of the command (rm -rf /var/lib/apt/lists/*) is no longer necessary because it never ends up in the layer. The result is a smaller image and faster installs.


# Dockerfile: APT packages with BuildKit cache mounts (no cleanup needed)
# syntax=docker/dockerfile:1.6
FROM debian:bookworm-slim AS base

# Both APT cache dirs mounted, neither ends up in the image layer
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt \
    --mount=type=cache,id=apt-lib,target=/var/lib/apt \
    apt-get update && apt-get install -y \
      git \
      curl \
      libpq-dev \
      libxml2-dev \
      unzip \
    && echo "Packages installed, cache stays warm for next build"

# npm build stage with cache mounts for both package download and build tools
FROM node:22-alpine AS frontend

WORKDIR /build
COPY package.json package-lock.json ./

RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
    npm ci --prefer-offline

COPY web/tailwind/ ./web/tailwind/

# Vite/Tailwind build cache, dramatically speeds up CSS-only changes
RUN --mount=type=cache,id=vite-cache,target=/build/.vite \
    npm run build

# Result: only compiled assets copied to final stage
FROM nginx:alpine AS static-server
COPY --from=frontend /build/dist/ /usr/share/nginx/html/

7. Layer Order: The Underrated Performance Factor

The order of the COPY and RUN instructions in the Dockerfile determines how effectively the layer cache is used. The rule: what rarely changes comes first, what changes often comes last. In practice, for PHP projects this means composer.json and composer.lock are copied before the application source code, so that a pure Composer layer gets built that only becomes invalid when the dependencies change. The source code, which changes with every commit, then goes into a separate layer afterward.

A common mistake is mixing rarely changed and frequently changed files into a single COPY instruction: COPY . . copies everything in one layer, invalidates it on every change to any file, and turns every subsequent layer (including the Composer install) into a cache miss. That costs minutes on every single commit. The solution is explicit, staged copying: configuration files first, then dependency manifests, then source. This layer strategy works independently of BuildKit cache mounts and adds up with them to maximum build performance.

8. Registry Cache for CI Environments Without Persistent Disk

Ephemeral CI runners, the default in GitHub Actions, GitLab CI with autoscaling and other modern CI systems, start each job from a clean state. Local Docker layer cache is gone after the job. BuildKit cache mounts are gone after the job. Only the registry cache survives job boundaries, because it is written to a container registry. The BuildKit flag --cache-to type=registry,ref=...,mode=max exports the entire build cache, including all intermediate layers, into a special cache manifest structure in the registry.

On the next build, --cache-from type=registry,ref=... imports this cache and makes it usable for every layer caching decision. The result: even on a fresh CI runner that has never built this image before, the build benefits from the layer cache of all previous builds. Combined with cache mounts for the package manager, this produces a multi-tier caching setup: registry cache for layers, cache mounts for package manager directories. Both tiers together reduce the CI build time to the minimum required by content that has actually changed.

9. Comparing Cache Strategies

Not every caching strategy fits every CI environment. The choice depends on runner persistence, registry availability and Dockerfile complexity.

Cache Strategy Granularity Ephemeral Runners Recommendation
Layer Cache (local) Whole RUN step Lost after job Only on self-hosted runners
BuildKit Cache Mounts Package manager cache Lost after job Combine with registry cache
Registry Cache (inline) All changed layers Persistent Standard for ephemeral runners
Registry Cache (max mode) All layers incl. intermediate Persistent Best hit rate, more registry storage
GitHub Actions Cache Layer hash based Persistent (7 days) Simplest option for GitHub Actions

The optimal combination for GitHub Actions is type=gha as the cache type, which uses the GitHub Actions cache as its backend. For GitLab with its own registry, type=registry with a dedicated cache registry is the most robust solution. Self-hosted runners can rely on persistent disk cache and use BuildKit cache mounts without any registry overhead. In every case, layer order in the Dockerfile remains the first optimization step, because it works independently of which cache backend is chosen.

Mironsoft

CI/CD optimization, Docker build performance and fast deployment pipelines

Want to cut your CI build times to a minimum?

We analyze your Dockerfiles and CI configuration, identify cache gaps, and implement BuildKit cache mounts, registry cache and an optimized layer order for short build times in your pipelines.

Dockerfile Audit

Identify layer order, cache invalidation points and cache mount potential

BuildKit Migration

Implement cache mounts for Composer, npm, APT and build tools

Registry Cache Setup

Cache-to/from configuration for GitHub Actions, GitLab CI and your own runners

10. Summary

Docker BuildKit cache mounts with RUN --mount=type=cache are the single most effective lever for reducing CI build times in package-manager-heavy projects. They separate the package manager cache from the image layer: the cache survives between builds but never ends up in the final image. Combined with an optimized layer order (rare changes first) and registry cache for ephemeral runners, this produces a three-tier caching setup that reduces build times to the minimum required by content that has actually changed.

The implementation is concrete and can be done step by step: first review and optimize the layer order. Then add cache mounts for Composer, npm and APT. Finally configure registry cache for the CI system. Each step measurably reduces build time and can be deployed on its own. The syntax=docker/dockerfile:1.6 directive at the top of the Dockerfile ensures that all BuildKit features are available, regardless of the Docker version installed on the runner.

BuildKit Cache Mounts, the Essentials at a Glance

Cache Mounts

RUN --mount=type=cache,id=...,target=...: package manager cache persists, never in the image layer. For Composer, npm, APT and build tools.

Layer Order

Rare changes first. Copy composer.lock before source code. Avoid COPY . ., it costs every subsequent layer on every commit.

Registry Cache

--cache-to type=registry,mode=max and --cache-from type=registry for ephemeral runners. Survives job boundaries, persists in the registry.

Enabling BuildKit

DOCKER_BUILDKIT=1 or syntax=docker/dockerfile:1.6. Default since Docker 23. Use --progress=plain for cache hit/miss debugging.

11. FAQ: Docker BuildKit Cache Mounts and Faster CI Builds

1What is a BuildKit cache mount?
A persistent directory mounted during the RUN step but not included in the image layer. Package manager cache survives builds, only changed packages get downloaded again.
2Enable BuildKit in GitHub Actions?
docker/setup-buildx-action@v3 + docker/build-push-action@v5, active automatically. Configure cache with type=gha.
3Cache mounts on ephemeral runners?
Stored locally, lost after the job. Combine with registry cache (--cache-to type=registry) for ephemeral runners.
4Why composer.json before COPY . .?
So the Composer install layer only becomes invalid on dependency changes, not on every source code commit.
5Do cache mounts increase image size?
No. Cache mount content does not appear in the image layer, it is explicitly separated from image persistence.
6mode=min vs. mode=max for registry cache?
min: only the final image layers. max: all intermediate layers. max has a higher hit rate for multi-stage builds but needs more registry storage.
7Same cache mount across multiple Dockerfiles?
Yes, via the same id option. Monorepos with multiple services can share the same package cache.
8Debug cache hits during the build?
--progress=plain shows CACHED versus fresh executions. Package manager output shows the cache origin.
9Why is rm -rf /var/lib/apt/lists/* not needed?
The APT cache is mounted via a cache mount and never ends up in the image layer. Deleting it was only needed to reduce layer size.
10Biggest win for PHP CI builds?
Composer cache mount plus correct layer order. Reduces the download portion to zero when dependencies are unchanged.