Docker Layer Caching: Stop Rebuilding Dependencies Every CI Run
Docker layer caching explained: why your builds are slow, how caching works, and the exact Dockerfile patterns to cut CI time by 80%..
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
- ✓Basic Dockerfile syntax (FROM, RUN, COPY, CMD)
- ✓Familiarity with docker build and docker push/pull commands
- ✓Understanding of CI/CD pipelines (GitHub Actions, Jenkins, etc.)
Docker caches each instruction's result as a layer. If the instruction and its context (e.g., file contents) haven't changed, Docker reuses the cached layer instead of re-executing. To maximize cache hits, put infrequently changing steps (like installing OS packages) early in the Dockerfile, and frequently changing steps (like copying source code) late.
Imagine you're baking a layered cake. Each layer takes time to bake. If you bake the same bottom layer again tomorrow, you don't throw away yesterday's bottom layer — you reuse it and only bake the new top layers. Docker does the same: it reuses unchanged layers and only rebuilds the ones that changed. But if you change the recipe for the bottom layer (like a different flour), you have to rebake everything above it too.
You just pushed a one-line comment change to your README. Your CI pipeline kicks off. Ten minutes later, you're still waiting for Docker to reinstall every npm package from scratch. That's not just frustrating — it's burning money on compute time and killing developer velocity. The culprit? A Dockerfile that ignores layer caching.
Docker's build cache is the single most impactful lever for speeding up container builds. When used correctly, it can turn a 10-minute build into a 2-minute one. When ignored, it punishes every commit with unnecessary rebuilds of dependencies that haven't changed. This isn't a nice-to-have optimization — it's the difference between a CI pipeline that keeps up with your team and one that becomes the bottleneck.
By the end of this article, you'll be able to write Dockerfiles that maximize cache hits, diagnose why a cache is being invalidated, and set up multi-stage builds that keep your final images small without sacrificing build speed. You'll also know the edge cases where caching works against you — and how to avoid them.
How Docker Build Cache Actually Works
Every Dockerfile instruction creates a layer — a read-only snapshot of the filesystem at that point. When you run docker build, Docker checks if it has a cached layer matching the instruction and its context (the files copied, the command string, etc.). If the instruction text is identical and the context hasn't changed, Docker reuses the cached layer. Otherwise, it executes the instruction and all subsequent instructions are rebuilt.
This means the order of instructions is everything. Put stable, infrequently changing steps first: OS package installs, language runtime installs, dependency downloads. Put frequently changing steps last: copying application source code, running tests, building assets.
Here's the critical detail most tutorials skip: Docker uses the instruction string AND the checksum of the build context (files copied via COPY/ADD) to determine cache validity. If you COPY . ., any change to any file in the context directory invalidates that layer — and every layer after it. That's why separating dependency manifests from source code is so powerful.
docker build --cache-from in CI to pull a previously built image as a cache source. This is essential when your CI runner doesn't share a local cache between builds.The COPY Order Trap: Why Your Cache Is Always Missing
The most common cache invalidation mistake is a single COPY . . early in the Dockerfile. Every time any file changes — even a README — Docker sees a different checksum for the context and invalidates that layer. Since all subsequent layers depend on it, the entire build after that point is redone.
The fix is brutally simple: copy only the files that change infrequently first, then copy the rest later. For a Node.js app, that means:
- COPY package.json and package-lock.json
- RUN npm install
- COPY the rest of the source code
The same pattern applies to Python (requirements.txt), Go (go.mod, go.sum), Java (pom.xml or build.gradle), and every other ecosystem. The dependency manifest changes only when you add/remove a dependency — which is far less often than source code changes.
But there's a subtler trap: using COPY . . even after installing dependencies still invalidates the layer if any file in the context changes. That's fine — you want that layer to rebuild when source changes. The key is that the npm install layer before it remains cached.
ADD with a URL or git repository, Docker does NOT cache based on the content — only the URL string. The layer will be rebuilt every time, even if the remote content hasn't changed. Use RUN curl ... instead, and manage caching manually.Multi-Stage Builds: Speed Without Bloat
Multi-stage builds let you use multiple FROM statements in a single Dockerfile. Each stage can have a different base image. You can copy artifacts from earlier stages into later ones. This is the standard pattern for keeping final images small while still having a full build environment.
But multi-stage builds also affect caching. Each stage is cached independently. If you change source code, only the stages that COPY that source code are invalidated. The base stage with OS packages and the dependency install stage remain cached.
Here's the pattern I use in production: a base stage with the runtime and system dependencies, a build stage with dev dependencies and source code, and a production stage that copies only the runtime artifacts. The base stage almost never changes. The build stage changes frequently but is thrown away. The production stage is rebuilt from cached layers every time.
One gotcha: if you use COPY --from=build in the production stage, that COPY instruction's cache depends on the checksum of the source stage's filesystem. If the build stage produces different files (e.g., a new build hash), the COPY layer in production is invalidated. That's expected — you want the new artifact.
Using BuildKit for Faster, Smarter Caching
Docker's classic builder has a cache that's tied to the local machine. If you switch machines or run in CI, the cache is empty. BuildKit (enabled by default in Docker 23.0+) introduces a more sophisticated cache system that can be exported and imported.
BuildKit's cache can be stored in a registry (inline with the image or as a separate cache manifest), on a local filesystem, or in cloud storage (S3, GCS, Azure Blob). This means your CI runners can share a cache, even if they're ephemeral.
To use registry caching, pass --cache-from and --cache-to flags:
``bash docker buildx build --cache-from=type=registry,ref=myregistry.com/myapp:cache \ --cache-to=type=registry,ref=myregistry.com/myapp:cache,mode=max \ -t myregistry.com/myapp:latest . ``
The mode=max flag caches every layer, not just those exported to the final image. This gives the best cache hit rate but uses more storage.
One caveat: BuildKit's cache export is still experimental in some configurations. Test it in your CI before relying on it.
--cache-from with a stale cache tag that points to an old image. The cache layers must match the current Dockerfile instructions. If you change the base image or reorder instructions, the old cache is useless and may cause confusing errors.When Caching Breaks: Common Invalidation Scenarios
Even with a perfect Dockerfile, cache invalidation can happen for reasons outside your control. Here are the ones I've seen burn teams:
- Base image tags that move: If you use
node:18(without a specific digest), the tag can update to a new image. Docker sees a different base image checksum and invalidates everything. Always pin to a digest:node:18@sha256:abc123.... - Package manager lock files: If you don't commit your lock file (package-lock.json, yarn.lock, Gemfile.lock), the
COPYinstruction sees different content every time a dependency is resolved. Always commit lock files. - Build arguments:
ARGand--build-argvalues are part of the cache key. If you pass a build arg that changes (like a version number), it invalidates the layer that uses it and all subsequent layers. Use build args sparingly. - Timestamps in build output: If your build process embeds timestamps (e.g.,
newin a config file), the output changes every build, invalidating the layer. Strip timestamps or use deterministic builds.Date() - Network-dependent instructions:
RUN apt-get updatefetches package lists that change daily. Even if the instruction is the same, the downloaded content differs. Consider pinning package versions or using a proxy cache.
docker pull node:18 periodically to get security updates, then update the digest in your Dockerfile. Automate this with Dependabot or Renovate.Cache Mounts: Speed Up Package Installs Without Layers
Sometimes you want to cache data that changes frequently but shouldn't create new layers. For example, the npm cache directory (~/.npm) or the Maven local repository (~/.m2). Using a cache mount with BuildKit lets you persist these directories across builds without bloating your image.
Cache mounts are specified with --mount=type=cache,target=<path>. They're not part of the image — they're stored on the host (or in a named volume in CI). This means:
- The cache survives between builds
- It doesn't increase image size
- It's not included in the final image layers
Here's the pattern for npm:
``dockerfile RUN --mount=type=cache,target=/root/.npm \ npm ci --only=production ``
This caches downloaded packages in /root/.npm. On subsequent builds, npm skips downloading packages that are already cached. The layer itself is still cached based on the instruction and context, but the cache mount makes the execution faster.
One warning: cache mounts are not automatically invalidated when dependencies change. If you update a package version, the new version will be downloaded and cached alongside the old one. Over time, the cache directory can grow large. Periodically clear it with docker builder prune --filter type=exec.cachemount.
DOCKER_BUILDKIT=1 or use docker buildx). In CI, ensure your Docker version supports BuildKit.CI-Specific Caching Strategies
In CI, you don't have the luxury of a persistent local Docker cache. Every runner starts fresh. Without a shared cache, every build is a full rebuild. Here's how to fix that:
- Use Docker's registry cache (BuildKit): Push cache layers to your container registry alongside the image. Pull them at the start of the next build. This is the most reliable approach.
- Use CI-native caching: GitHub Actions, GitLab CI, and CircleCI offer cache actions that can store Docker layers. For example, GitHub Actions'
docker/login-actionanddocker/build-push-actionsupportcache-fromandcache-to. - Save and load the Docker build cache manually: Export the cache to a tar file and store it as a CI artifact. This is clunky but works with any CI system.
- Use a Docker registry as a cache proxy: Run a local Docker registry (or use Docker Hub) and push/pull cache images. This is essentially the same as option 1 but without BuildKit's
--cache-fromsyntax — you just pull a previous build image and use--cache-frompointing to it.
Here's a GitHub Actions example that uses registry caching:
mode=max for cache-to only if you have ample registry storage. For most teams, mode=min (caches only layers in the final image) is sufficient and uses less space.Debugging Cache Misses: Tools and Techniques
When your cache isn't hitting, you need to find out why. Here's my debugging toolkit:
docker build --progress=plain: Shows every step and whether it used cache. Look forCACHEDvsRunning in ....docker build --no-cache-filter=<stage>: Invalidate cache for a specific stage only. Useful for testing if a stage is the culprit.docker history <image>: Shows the layers in an image and their sizes. If a layer is unexpectedly large, it might be caching something you didn't intend.docker buildx du: Shows disk usage by BuildKit cache. Helps identify if cache mounts are growing out of control.- Compare checksums: If you suspect a COPY instruction is invalidating, compute the checksum of the context manually:
find . -type f -exec md5sum {} \; | sort | md5sum. Compare with the previous build's checksum. - Check
.dockerignore: If you're not ignoring unnecessary files (node_modules, .git, etc.), they're part of the build context and can cause cache misses. A good.dockerignoreis essential.
node_modules to .dockerignore means your local node_modules is copied into the image, then overwritten by npm install. This doubles the context size and can cause subtle bugs if the local and container versions differ.When NOT to Use Docker Layer Caching
Layer caching is powerful, but it's not always the right tool. Here's when I'd skip it:
- One-off builds: If you're building an image once (e.g., a throwaway test environment), caching adds complexity without benefit. Just build without cache.
- Tiny images: If your image is <100MB and builds in <30 seconds, caching overhead isn't worth it. The cache lookup and storage management cost more than the rebuild.
- Highly dynamic dependencies: If your dependencies change every build (e.g., you're pulling the latest commit of a library), caching is useless. Consider pinning versions or using a different strategy.
- Security-sensitive builds: If you need to ensure no cached layers from previous builds are used (e.g., building with different secrets), use
--no-cacheor--secretmounts. - Ephemeral CI with no shared cache: If your CI doesn't support cache import/export and you can't use registry caching, the default cache is lost after the runner dies. You're better off optimizing the Dockerfile for speed without relying on cache.
The 15-Minute Build That Killed Deploys
COPY . . before RUN npm install. Every code change — even a whitespace edit — invalidated the npm install layer, forcing a full reinstall of all 1,200 packages.COPY package.json package-lock.json ./ then RUN npm ci then COPY . .. Build time dropped from 15 minutes to 90 seconds.- Always copy dependency manifests before source code.
- A single misplaced COPY can cost your team hours every week.
npm install even when package.json hasn't changedCOPY package.json package-lock.json ./ comes before RUN npm ci and COPY . . comes after.
2. Verify that package-lock.json is committed and not in .dockerignore.
3. Run docker build --no-cache once to reset, then rebuild to confirm cache hits.DOCKER_BUILDKIT=1 or use docker buildx.
2. Use --cache-from and --cache-to with a registry (e.g., type=registry,ref=myregistry.com/myapp:cache).
3. In CI, pull the cache image before building: docker pull myregistry.com/myapp:cache || true..dockerignore.
3. Run docker history <image> to identify large layers.docker build --progress=plain . 2>&1 | grep -E '(CACHED|Running in)'docker history <image-name> --no-trunc | head -20| File | Command / Code | Purpose |
|---|---|---|
| Dockerfile.cache-optimized | FROM node:18-alpine AS base | How Docker Build Cache Actually Works |
| Dockerfile.bad-order | FROM node:18-alpine | The COPY Order Trap |
| Dockerfile.multistage | FROM node:18-alpine AS base | Multi-Stage Builds |
| build-cache-commands.sh | docker buildx build \ | Using BuildKit for Faster, Smarter Caching |
| Dockerfile.pinned-base | FROM node:18@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456... | When Caching Breaks |
| Dockerfile.cache-mount | FROM node:18-alpine AS deps | Cache Mounts |
| .github | name: Docker Build with Cache | CI-Specific Caching Strategies |
| .dockerignore | .git | Debugging Cache Misses |
Key takeaways
Interview Questions on This Topic
How does Docker determine whether to use a cached layer for a RUN instruction?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
That's Docker. Mark it forged?
7 min read · try the examples if you haven't