Docker Layer Caching: Why Your Builds Are Slow (and How to Fix It)

intermediate 12 min read updated 12 Jul 2026
On this page 10

Introduction to Docker Layer Caching

Every line in your Dockerfile isn’t just an instruction; it’s a potential Docker image layer. When you build an image, Docker executes each instruction, committing the filesystem changes into a new, read-only layer. These layers stack on top of each other, forming your final image. This layered architecture is foundational to Docker’s efficiency, primarily through its build cache.

Consider this Dockerfile snippet:

FROM node:18-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build

Each of FROM, WORKDIR, COPY, RUN creates a distinct layer. Docker maintains a local build cache of previously built layers. When you run docker build, it doesn’t just execute instructions blindly. For each step, Docker first checks if it has an identical layer in its cache.

The cache lookup mechanism is straightforward: Docker compares the current instruction with layers in its cache. For RUN instructions, it also considers the command itself. For ADD or COPY instructions, it compares the instruction and a checksum of the source files being added. If an exact match is found, it’s a cache hit. Docker reuses that existing layer, skipping its execution entirely. This is fast.

A cache miss, however, means Docker must execute the instruction. Crucially, once a cache miss occurs for any instruction, all subsequent instructions in the Dockerfile will also result in cache misses, even if their content hasn’t changed. Docker must rebuild all layers from that point onward. This is the primary reason for slow builds.

For instance, if package.json changes in the example above, the COPY package.json . instruction will miss the cache. Consequently, RUN npm install will also miss, even if node_modules is identical. Then, COPY . . and RUN npm run build will miss too. A small change early in the Dockerfile can invalidate a large portion of your build, forcing Docker to re-execute time-consuming steps like dependency installation or code compilation.

Understanding this layer invalidation cascade is non-negotiable. It dictates how you structure your Dockerfile to maximize cache hits for stable, expensive operations, and minimize the impact of frequent changes. Without this insight, your “fast” Docker builds will consistently crawl.

The Anatomy of a Fast Dockerfile: Ordering Matters

Docker builds are fundamentally a series of layered operations. Each instruction in your Dockerfile translates to a distinct layer. Docker attempts to reuse cached layers from previous builds if the instruction, and its context, haven’t changed. This is the bedrock of efficient builds.

The critical insight lies in how cache invalidation propagates. When Docker encounters an instruction, it checks if an identical layer exists in its cache. If it finds one, it reuses it. If any instruction changes, or if the context for an instruction (like COPY . . when a file in . changes) invalidates it, then that instruction’s cache is busted. Here’s the kicker: all subsequent instructions in the Dockerfile will also have their cache invalidated, regardless of whether they themselves changed. They must be rebuilt from scratch.

Consider a common anti-pattern: copying all your application code before installing dependencies.

# BAD EXAMPLE: Every code change busts the dependency cache
FROM node:18-alpine
WORKDIR /app
COPY . .               # <-- This copies ALL application files
RUN npm install        # <-- Cache for this layer is busted on every file change
CMD ["npm", "start"]

In this scenario, COPY . . copies your entire project directory into the image. Even a single character change in any source file will invalidate this COPY layer. Because RUN npm install comes after it, Docker will be forced to re-run npm install on every single build, even if your package.json (and package-lock.json) hasn’t changed. This is often the primary culprit for agonizingly slow development builds.

The fix is simple but profound: order your instructions from least frequently changing to most frequently changing. For dependency-based projects, this means copying only the dependency manifest files, installing dependencies, and then copying the rest of your application code.

# GOOD EXAMPLE: Leverages cache effectively
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # <-- Only copies dependency manifests
RUN npm ci --prefer-offline             # <-- This layer only busts if package.json/lock changes
COPY . .                                # <-- This layer busts on code changes, but is fast
CMD ["npm", "start"]

Here, COPY package.json package-lock.json ./ only invalidates its cache if those specific files change. The RUN npm ci instruction, which is typically the most time-consuming step, will only execute if the dependency manifest files have been modified. Once npm ci completes and its layer is cached, subsequent builds will reuse this layer as long as your dependencies are stable. Only the final COPY . . layer, and subsequent layers, will rebuild when your application code changes. This dramatically speeds up iterative development, as most code changes only trigger a fast COPY operation, not a full dependency reinstall.

This principle applies universally: place expensive, stable operations early in your Dockerfile. Put volatile, frequently changing operations late. It’s the single most impactful factor in your Docker build times.

Practical Strategies for Cache Optimization

To genuinely speed up Docker builds, you must understand that layer invalidation is the primary enemy. Docker builds sequentially; any change in a layer invalidates all subsequent layers. Our goal is to front-load the most stable parts of your build process.

Strategic Dependency Grouping

The first principle is to copy the least-frequently-changing files into the build context before anything else. Your application’s source code changes constantly, but its core dependencies (like package.json or go.mod) change far less often.

Consider a typical Node.js application. A naive Dockerfile might look like this:

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN pnpm install
CMD ["node", "src/index.js"]

Every time you change any source file, COPY . . invalidates the layer, forcing pnpm install to run again, even if package.json hasn’t changed. This is a massive time sink.

The fix is simple: copy only the dependency manifests first, install, then copy your source code.

FROM node:20-alpine
WORKDIR /app
# 1. Copy only dependency manifests
COPY package.json pnpm-lock.yaml ./
# 2. Install dependencies. This layer is highly cacheable.
RUN pnpm install --frozen-lockfile
# 3. Copy application source code.
COPY . .
# 4. Any build steps for your code.
RUN pnpm build # if applicable
CMD ["node", "dist/index.js"]

Now, if only your application’s source code (.js, .ts, .vue files, etc.) changes, the COPY package.json... and RUN pnpm install layers remain cached. Docker only rebuilds from COPY . . onwards, saving minutes on dependency installation. Apply this pattern to any language: requirements.txt for Python, go.mod/go.sum for Go, pom.xml for Java, etc.

Leveraging Multi-Stage Builds

Multi-stage builds are critical for two reasons: they drastically reduce final image size, and they further optimize caching by isolating build environments. You define multiple FROM instructions, each creating a new stage. Only artifacts explicitly copied from a previous stage are included in the next.

This allows you to use a fat “builder” image with all necessary compilers and tools, then copy only the compiled artifacts or installed dependencies to a lean, production-ready “runtime” image.

# Stage 1: Builder for dependencies and compilation
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build # Example: for a frontend or TypeScript backend

# Stage 2: Production runtime
FROM node:20-slim AS production
WORKDIR /app
# Copy only the necessary node_modules from the builder stage
COPY --from=builder /app/node_modules ./node_modules
# Copy the compiled application code
COPY --from=builder /app/dist ./dist
# If you have static assets or other files not handled by 'dist'
# COPY --from=builder /app/public ./public
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]

The “why” here is multi-faceted:

  1. Smaller Images: The production stage doesn’t contain pnpm itself, build tools, or development dependencies. This means fewer layers and a smaller attack surface.
  2. Improved Cache Granularity: If your pnpm install layer in the builder stage is cached, and only your source code changes, the builder stage will rebuild from COPY . . and pnpm build. However, the production stage’s COPY --from=builder commands will only invalidate if the content of /app/node_modules or /app/dist actually changes. If pnpm build produces the same output (e.g., no code changes), the production stage might also hit its cache.
  3. Clear Separation: Build-time concerns are cleanly separated from runtime concerns, leading to more robust and maintainable Dockerfiles.

Combine dependency grouping with multi-stage builds for the best results. The pnpm install in the builder stage is still strategically placed for caching, and the final image benefits from being minimal.

Common Cache Busters and How to Avoid Them

Docker’s build cache is a powerful tool, but it’s easily defeated by common Dockerfile anti-patterns. Understanding why these patterns bust your cache is key to building faster, more reliable images.

Premature COPY . or ADD .

This is the single most frequent cache buster. You’ll often see something like:

# BAD: Copies everything too early
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm install
COPY src/ . # Redundant, but illustrates the point
ENTRYPOINT ["node", "src/index.js"]

Why it busts the cache: The COPY . . instruction copies every file from your build context into the image. If even a single file in that context changes – a README, a test file, or a temporary editor file – this layer’s cache is invalidated. Consequently, all subsequent layers, including your npm install, will rerun from scratch. This is a massive waste of time for builds where only application code, not dependencies, has changed.

How to fix it: Copy only the files necessary for dependency installation first. Install dependencies, then copy the rest of your application code. This leverages the cache for stable dependency layers.

# GOOD: Copies dependencies first, then application code
FROM node:18-alpine
WORKDIR /app

# Copy only dependency manifests
COPY package.json package-lock.json ./
# Use npm ci for deterministic installs based on lock file
RUN npm ci --prefer-offline --no-progress

# Copy the rest of the application code
COPY . .

ENTRYPOINT ["node", "src/index.js"]

Now, if only your application’s source code changes, Docker can reuse the cached npm ci layer, saving significant build time.

Volatile Build Arguments (ARG)

ARG instructions define variables that can be passed at build time (docker build --build-arg KEY=VALUE). While useful, using them carelessly can invalidate your cache.

# BAD: BUILD_DATE changes on every build, busting the RUN command
FROM ubuntu:22.04
ARG BUILD_DATE=$(date -Iseconds) # This changes on every build
RUN echo "Image built on ${BUILD_DATE}" > /etc/build_info.txt
RUN apt update && apt install -y some-package

Why it busts the cache: If an ARG’s value changes, any RUN command that references it will invalidate its cache. In the example above, BUILD_DATE changes on every build, causing the echo command layer (and all subsequent layers) to rerun, even if some-package is already cached.

How to fix it: Define volatile ARGs as late as possible in your Dockerfile, after your stable dependency layers. If an ARG is purely informational and not used in a RUN command that modifies the filesystem, it won’t bust the cache. For truly static values, use ENV.

# GOOD: BUILD_DATE defined later, after stable layers
FROM ubuntu:22.04
RUN apt update && apt install -y some-package # This layer is stable and cached

ARG BUILD_DATE="unknown" # Define a default, or pass via --build-arg
# Only use the volatile ARG after stable layers
RUN echo "Image built on ${BUILD_DATE}" > /etc/build_info.txt

This ensures that your system package installations remain cached, even if you update the BUILD_DATE for logging purposes.

Unnecessary RUN Command Combinations

While combining RUN commands is generally good practice to reduce layer count, doing so indiscriminately can prevent cache reuse for stable parts of your build.

# BAD: Combining stable OS updates with volatile application setup
FROM python:3.10-slim-buster
RUN apt update && apt install -y git build-essential \
    && pip install --no-cache-dir -r requirements.txt

Why it busts the cache: If requirements.txt changes, the pip install command will rerun. Because it’s combined with apt update and apt install, the entire layer is invalidated. This means your system packages will be re-downloaded and re-installed even if they haven’t changed.

How to fix it: Split RUN commands into logical, stable units. Place the most stable commands (like OS package installations) earlier in the Dockerfile.

# GOOD: Separating stable OS installs from potentially volatile Python dependencies
FROM python:3.10-slim-buster

# Stable system dependencies
RUN apt update && apt install -y --no-install-recommends git build-essential \
    && rm -rf /var/lib/apt/lists/* # Clean up apt cache immediately

# Copy Python dependencies and install
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY . .

Now, if only your requirements.txt changes, Docker can reuse the cached apt install layer, significantly speeding up subsequent builds. Remember to clean up apt caches in the same layer to keep image size down.

Advanced Cache Techniques and CI/CD Integration

Local BuildKit cache is great, but CI/CD environments often start fresh. Your build server might be a new VM every time, or a clean Docker container. This means the docker build command sees no previous layers on disk. This is where advanced cache mechanisms become critical.

BuildKit’s --cache-from flag is your first line of defense against slow CI builds. Instead of relying solely on a local cache directory, --cache-from instructs BuildKit to look for layers in an existing image, either locally or in a remote registry.

Consider a multi-stage build. If your builder stage hasn’t changed, but your CI runner has no local cache, BuildKit will rebuild it from scratch. With --cache-from, you can point to a previously built version of your application image.

# Dockerfile
FROM node:18-alpine AS builder
# ... install dependencies, build frontend ...

FROM node:18-alpine AS production
# ... copy artifacts ...

When you build, you’d execute:

docker build --cache-from your_registry/your_app:latest .

BuildKit will pull metadata for your_registry/your_app:latest and attempt to match layers. If a layer’s content address matches, it’s reused. This significantly speeds up builds where earlier stages are stable.

For true CI/CD optimization, you need to manage cache externally. The docker buildx command, which leverages BuildKit’s full power, allows you to push and pull build cache to a remote registry. This effectively turns your registry into a shared cache store for all your CI runners.

First, ensure BuildKit is enabled and configured. Docker Desktop 20.10+ typically uses BuildKit by default. For CI, you might need to explicitly create a builder instance:

docker buildx create --name mybuilder --use
docker buildx inspect --bootstrap

Now, when building, you can instruct BuildKit to push all intermediate layers as cache manifests to a registry. The mode=max option is crucial here; it ensures every cacheable layer is pushed, not just the layers forming the final image:

docker buildx build \
  --platform linux/amd64 \
  --tag your_registry/your_app:latest \
  --cache-to type=registry,ref=your_registry/your_app_cache:latest,mode=max \
  --push .

This command builds, pushes the final image, and also pushes a separate cache manifest to your_registry/your_app_cache:latest.

On subsequent CI builds, you pull this cache before building:

docker buildx build \
  --platform linux/amd64 \
  --tag your_registry/your_app:latest \
  --cache-from type=registry,ref=your_registry/your_app_cache:latest \
  --push .

BuildKit will fetch the cache manifest from your_registry/your_app_cache:latest, then download only the necessary layers for the current build context. This dramatically reduces build times, especially for projects with many dependencies or multi-stage builds. Treat your cache image (your_registry/your_app_cache:latest) like any other image: it needs to be accessible and managed. This strategy is the most robust way to achieve consistent, fast builds across ephemeral CI environments.