Back to Blog
DevOpsJuly 9, 20264 min read

Debugging Docker BuildKit Cache Mounts for Faster Builds

Docker buildkit cache mounts are powerful, but they often fail silently. Learn how to debug persistent layer issues and optimize your build performance.

dockerbuildkitdevopsperformanceoptimizationcontainers

We’ve all been there: you add a simple line to your Dockerfile, and suddenly a build that usually takes 10 seconds drags on for five minutes because the cache decided to invalidate everything. It’s the kind of friction that kills your flow. When I started working heavily with multi-stage builds, I realized that docker buildkit cache mounts are the single most effective way to keep your dependency installation fast, but they’re also notorious for being a "black box" when things go wrong.

If you aren't seeing the speed gains you expect, you’re likely dealing with a broken cache path or an invalidation trigger you didn't account for.

Why Your Cache Mounts Aren't Sticking

The most common mistake I see is assuming the cache mount is persistent across different build environments. It isn't. BuildKit cache mounts are local to the builder instance. If you’re running in a CI environment (like GitHub Actions or GitLab Runner) where the runner spins up and tears down, that cache is gone unless you’ve explicitly configured a remote cache backend.

We once spent about two days chasing a performance bottleneck because we assumed our npm cache was persisting. It turned out we were using a standard Docker runner that didn't share the BuildKit state between jobs.

To verify if your cache is actually working, you can use the --progress=plain flag to see exactly what’s happening during the RUN command:

Bash
docker build --progress=plain --no-cache .
# Or, to check if the cache is hit:
docker build --build-arg BUILDKIT_INLINE_CACHE=1 .

If you see your package manager downloading everything from scratch every time, your cache mount is likely misconfigured.

Implementing Optimized Cache Mounts

To get the most out of docker buildkit cache mounts, you need to mount the directory where your package manager stores its files. For Node.js, that’s usually ~/.npm. For Python, it’s ~/.cache/pip.

Here is the standard pattern I use in my own Dockerfile configurations:

Dockerfile
# syntax=docker/dockerfile:1
FROM node:20-slim AS base
WORKDIR /app
COPY package*.json ./

# The magic happens here:
RUN --mount=type=cache,target=/root/.npm \
    npm ci

By mounting the /root/.npm directory, you’re telling Docker to keep that directory persistent across builds. If you change a single file in your source code, the next build will skip the npm ci download phase entirely.

Comparison of Build Optimization Strategies

StrategyPerformance GainComplexityBest For
Standard COPY/RUNLowMinimalTiny projects
BuildKit Cache MountsHighMediumHeavy dependencies
Multi-stage BuildsVery HighHighReducing image size
Remote Cache BackendHighHighDistributed CI/CD

Troubleshooting Common Performance Bottlenecks

If you’ve set up the mounts and docker build performance is still lagging, you need to look at layer ordering. I’ve written previously about Docker build cache debugging: Fix slow incremental builds, and the golden rule remains: put the things that change the least at the top of your Dockerfile.

One subtle issue I’ve encountered is "cache poisoning." If you accidentally COPY . . before your RUN command, any minor change to a README or a random config file will invalidate your entire dependency layer. Always copy your lock files first, run your install, and then copy your application source.

If you're still hitting walls, you might want to look into Docker image size reduction: How to analyze layers with Dive to see if you're pulling in unnecessary bloat that makes the layer synchronization slower than it needs to be.

Moving Forward

If you're still struggling with slow builds in your pipeline, it might be time to audit your entire CI/CD setup. I often help teams streamline these workflows through CI/CD Pipeline & Docker Containerization to ensure that their local development speed matches their deployment speed.

Next time you’re debugging a build, don't just stare at the terminal output. Use docker buildx du to check how much space your cache is taking up and whether it’s actually being pruned improperly. I’m still experimenting with different --mount=type=cache sharing modes (like shared vs private), as the default can sometimes lead to unexpected locking issues in parallel build environments. Keep an eye on your disk I/O; if your storage is slow, the cache mount might be adding overhead rather than saving time.

FAQ

Q: Does type=cache persist if I stop the Docker daemon? A: Yes, it persists on the host disk within the BuildKit state directory, provided you don't run a full system prune.

Q: Why is my cache mount ignored in GitHub Actions? A: GitHub Actions runners are ephemeral. You must use the docker/build-push-action with cache-from and cache-to configured to use a GitHub Actions cache backend, or the local mount won't persist between job runs.

Q: Can I share a cache mount between different Dockerfiles? A: Yes, by using the id attribute, e.g., --mount=type=cache,id=my-npm-cache,target=/root/.npm. This allows different builds to share the same persistent cache volume.

Similar Posts