← Back to DevBytes

Docker Layer Caching: Best Practices and Common Pitfalls

What is Docker Layer Caching?

Every Docker image is built from a series of instructions in a Dockerfile. Each instruction—like FROM, RUN, COPY, or ADD—creates a new layer in the final image. These layers are stacked on top of each other and are immutable. Docker's build system can reuse these layers if they haven't changed, which is the core idea behind layer caching.

When you run docker build a second time, Docker checks each instruction against its cache. If the instruction and its context (the files or commands it depends on) are identical to a previous build, Docker reuses the cached layer instead of executing the instruction again. This dramatically reduces build time and ensures consistent, reproducible images.

How caching works under the hood

Docker uses a content-addressable cache key for each layer. The key is generated from:

If any part of the key changes, the cache is invalidated for that layer, and all subsequent layers must be rebuilt—even if they haven't changed logically. This cascading invalidation is the most important behavior to understand when designing a Dockerfile for optimal caching.

Why Layer Caching Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Efficient layer caching is not just about saving a few seconds locally. It impacts:

A poorly ordered Dockerfile can cause a full rebuild for a trivial change, while a well-optimized one can complete in milliseconds. Understanding layer caching is therefore a fundamental skill for anyone working with containers in production.

How to Use Layer Caching Effectively

The golden rule: place instructions that change frequently later in the Dockerfile, and keep stable, slow-to-execute instructions early. This maximizes cache hits for expensive steps like dependency installation while isolating fast-changing application code to the final layers.

Example: A naive Node.js Dockerfile

Consider this common anti-pattern:


# Bad: application code copied before npm install
FROM node:18-alpine

WORKDIR /app

COPY . .

RUN npm install

CMD ["node", "server.js"]

In this example, any change to any file in the project (including a single line in a markdown file) will invalidate the COPY . . layer. That forces RUN npm install to execute again from scratch, downloading all dependencies every time. This is painful for developers.

Optimized version

Separate dependency installation from application code:


# Good: package.json and lock file copied first
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./

RUN npm ci --only=production

COPY . .

CMD ["node", "server.js"]

Now, changes to source files only bust the cache for the final COPY layer, leaving the npm ci layer intact. The dependency install step is reused until package.json or the lock file changes. This is a massive improvement.

Multi-stage builds and caching

Multi-stage builds allow you to compile or prepare artifacts in one stage and copy only the necessary output into a lean final image. Each stage has its own independent cache. By structuring stages carefully, you can cache intermediate build tools and avoid shipping them.


# Multi-stage Go build with cached dependencies
FROM golang:1.21 AS builder

WORKDIR /src

# Copy dependency definitions first
COPY go.mod go.sum ./
RUN go mod download

# Then copy source code
COPY . .

RUN go build -o /app/server .

# Final minimal image
FROM alpine:3.18

COPY --from=builder /app/server /usr/local/bin/server

CMD ["server"]

Here, the go mod download step is cached until go.mod or go.sum change. The source code changes only rebuild the compilation step, not the dependency download.

Best Practices for Docker Layer Caching

1. Order instructions by stability

Always put the least frequently changed instructions at the top. Typical order from top to bottom:

2. Combine RUN instructions and clean up in the same layer

Each RUN creates a separate layer. Merging related commands into a single RUN reduces layer count and ensures cleanup doesn't bloat the final image. The cache will treat the entire combined command as one unit.


# Good: update, install, and clean in one layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      curl \
      ca-certificates && \
    rm -rf /var/lib/apt/lists/*

Doing update, install, and delete in separate RUN lines would leave the package cache in an intermediate layer, wasting space and making caching less predictable.

3. Copy specific files, not entire directories

Instead of COPY . . early, copy only the files that are needed for the next step. Use wildcards and multiple COPY directives to precisely control cache invalidation.


# Precisely copy dependency manifests
COPY package.json package-lock.json ./

RUN npm ci

# Now copy application code
COPY src/ ./src/
COPY public/ ./public/

If your project has many files that don't affect the build (like docs, tests, or local configs), use a .dockerignore file to exclude them. This not only speeds up the build context transfer but also prevents unnecessary cache busting.

4. Leverage --mount=type=cache for package managers

Modern Docker (BuildKit) supports cache mounts that persist across builds even when the layer itself is rebuilt. This is extremely powerful for package manager caches (like npm, pip, apt). Instead of relying solely on layer caching, you can use:


# Using cache mount for npm (requires BuildKit)
RUN --mount=type=cache,target=/root/.npm \
    npm ci

Even when package.json changes, the npm cache directory is preserved, so unchanged packages are not redownloaded. This can cut installation time by an order of magnitude.

5. Be deliberate with ARG and ENV

Environment variables used in a RUN instruction become part of the cache key. If you pass a build argument like --build-arg VERSION=1.2.3, it will bust the cache for any RUN that references $VERSION. To minimize this:


# ARG only used in final image metadata, after the heavy lifting
FROM builder AS build
# ... heavy compilation steps that don't use VERSION

FROM alpine:3.18
ARG VERSION
LABEL version=${VERSION}
COPY --from=build /app/server /usr/local/bin/server

6. Use .dockerignore aggressively

The build context is sent to the Docker daemon before any caching logic runs. If your context contains thousands of unnecessary files (node_modules, .git, logs, IDE settings), the checksum calculation for COPY can be slow and cache-invalidating. A well-crafted .dockerignore file ensures only essential files are considered.


# Example .dockerignore
node_modules
.git
*.log
Dockerfile
docker-compose.yml
README.md

Common Pitfalls

Pitfall 1: ADD instead of COPY

ADD has extra features like URL fetching and tar auto-extraction. These break cache predictability because a URL might return different content even if the Dockerfile instruction is unchanged. Always prefer COPY unless you specifically need ADD's extra functionality. COPY is explicit and deterministic.

Pitfall 2: Accidentally busting the cache with dynamic content

Commands that fetch the current time, random numbers, or external data will always produce a different layer and invalidate the cache. For example:


# Bad: curl always fetches fresh data, busting cache
RUN curl -o /etc/motd https://example.com/motd.txt

If you must include external data, consider fetching it at container startup (via an entrypoint script) instead of during build, or use cache mounts and a pinned version.

Pitfall 3: Running apt-get update and apt-get install in separate layers

This is a classic mistake:


RUN apt-get update
RUN apt-get install -y nginx

If the first layer is cached (because the apt-get update command itself hasn't changed), you might be installing packages from an outdated package index. Always combine them in one RUN with cleanup.

Pitfall 4: Using ARG before FROM without understanding cache behavior

ARG before the first FROM is special: it's used only in FROM lines and does not persist. If you try to use it later without redeclaring, it's empty, leading to confusing cache breaks. Always redeclare ARG after FROM if you need it in later stages.

Pitfall 5: Relying on cache in CI without a proper cache backend

Local builds benefit from the local layer cache stored on disk. In ephemeral CI environments (like GitHub Actions or GitLab CI), each job may start without any cache. Without a remote cache (using --cache-from with a registry, or Docker BuildKit's inline cache), every build starts from scratch. Use:


# Example: using cache-from in CI
docker build --cache-from myapp:latest --tag myapp:new .

Modern solutions like docker buildx build --cache-to=type=registry push cache layers to a remote registry, making CI builds consistently fast.

Pitfall 6: Copying everything then filtering with .dockerignore incorrectly

.dockerignore follows a different syntax than .gitignore. A common mistake is using node_modules/ instead of node_modules (trailing slash matters). Always test what's included by running docker build --no-cache -t tmp . and inspecting, or use docker build -f Dockerfile --print-build-context with modern Docker.

Conclusion

Docker layer caching is one of the most impactful mechanisms for speeding up container builds and ensuring efficient CI/CD workflows. The key to mastering it is understanding cache invalidation: once a layer changes, everything below it must be rebuilt. By carefully ordering instructions, separating dependency installation from application code, using multi-stage builds, leveraging BuildKit cache mounts, and avoiding common pitfalls like dynamic commands or poorly scoped file copies, you can achieve near-instant rebuilds for everyday changes. Treat your Dockerfile as a blueprint for caching—each line is a decision that either preserves or destroys the cache. With the practices outlined here, you'll build faster, ship sooner, and spend less time staring at progress bars.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles