Skip to content
Codeloom
Docker

Docker Layer Caching Strategies to Speed Up Builds

Master Docker layer caching: instruction ordering, cache mounts, BuildKit inline cache, and CI registry caching for faster builds.

·6 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • How Docker layer caching works and why instruction order matters
  • BuildKit cache mounts for package managers
  • Registry-based caching for CI/CD pipelines

Prerequisites

Every instruction in a Dockerfile creates a layer. Docker caches each layer and reuses it if nothing has changed. When a layer’s cache is invalidated, every subsequent layer is rebuilt from scratch. Understanding this mechanism is the key to fast Docker builds.

How Layer Caching Works

Docker checks each instruction against its cache in order. For COPY and ADD, it compares file checksums. For RUN, it checks if the command string has changed. The moment any layer’s cache is invalid, all layers after it are rebuilt.

Consider this Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

Every time you change any source file, COPY . . invalidates the cache, and npm ci runs again even though your dependencies have not changed. A full npm ci on a large project can take minutes.

Strategy 1: Order Instructions by Change Frequency

Put things that change rarely at the top and things that change often at the bottom:

FROM node:20-alpine
WORKDIR /app

# Step 1: Copy only dependency files (change rarely)
COPY package.json package-lock.json ./

# Step 2: Install dependencies (cached unless package files change)
RUN npm ci

# Step 3: Copy source code (changes frequently)
COPY . .

# Step 4: Build (runs only when source changes)
RUN npm run build

EXPOSE 3000
CMD ["node", "dist/index.js"]

Now when you edit source code, only steps 3 and 4 run. The npm ci step is cached because package.json and package-lock.json did not change. On a project with 500+ dependencies, this saves 30-90 seconds per build.

The same pattern works for any language:

# Go
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server .

# Python
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY . .

# Rust
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main(){}" > src/main.rs
RUN cargo build --release
COPY src/ src/
RUN cargo build --release

Strategy 2: BuildKit Cache Mounts

Cache mounts persist package manager caches between builds without including them in the final image:

# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build

The npm cache directory is stored outside the image layers. On the next build, npm finds packages in the cache and skips downloading them. The cache mount is not included in any layer, so it does not increase image size.

Common cache mount targets:

# npm
RUN --mount=type=cache,target=/root/.npm npm ci

# pip
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

# Go modules
RUN --mount=type=cache,target=/go/pkg/mod go mod download

# Go build cache
RUN --mount=type=cache,target=/root/.cache/go-build go build -o server .

# apt
RUN --mount=type=cache,target=/var/cache/apt \
    apt-get update && apt-get install -y curl

Cache mounts require BuildKit. Enable it with DOCKER_BUILDKIT=1 or configure it as the default in /etc/docker/daemon.json.

Strategy 3: Use .dockerignore Effectively

A large build context slows down every build because Docker sends the entire context to the daemon before building. It also causes unnecessary cache invalidation when COPY . . picks up files that do not affect the build.

Create a thorough .dockerignore:

.git
.gitignore
node_modules
dist
build
coverage
test
tests
__tests__
*.test.js
*.spec.js
*.md
.env*
.vscode
.idea
docker-compose*.yml
Dockerfile*
.dockerignore

This reduces context transfer time and prevents test file changes from invalidating your source copy layer.

Strategy 4: Registry-Based Caching for CI

In CI environments, local layer cache is empty on every run. You can use registry-based caching to persist layers across builds.

Inline cache — embed cache metadata in the image:

# Build and push with cache metadata
docker buildx build \
  --cache-to type=inline \
  --push \
  -t registry.example.com/myapp:latest .

# Next build uses the pushed image as cache
docker buildx build \
  --cache-from type=registry,ref=registry.example.com/myapp:latest \
  -t registry.example.com/myapp:$(git rev-parse --short HEAD) .

Registry cache — store cache layers separately from the image:

docker buildx build \
  --cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
  --cache-from type=registry,ref=registry.example.com/myapp:buildcache \
  --push \
  -t registry.example.com/myapp:latest .

The mode=max flag caches all layers, including intermediate stages in multi-stage builds. Without it, only the final stage layers are cached.

GitHub Actions example:

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/myorg/myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

The type=gha cache backend uses GitHub Actions cache storage, which persists across workflow runs.

Strategy 5: Selective COPY for Monorepos

In a monorepo, avoid copying the entire repo when only one service needs to build:

FROM node:20-alpine AS builder
WORKDIR /app

# Copy workspace root dependency files
COPY package.json package-lock.json ./
COPY packages/shared/package.json ./packages/shared/
COPY packages/api/package.json ./packages/api/

# Install all workspace dependencies
RUN npm ci

# Copy only the packages this service needs
COPY packages/shared/ ./packages/shared/
COPY packages/api/ ./packages/api/

# Build
WORKDIR /app/packages/api
RUN npm run build

Changes to packages/frontend/ do not invalidate the cache for the API build.

Strategy 6: Combine RUN Instructions Wisely

Each RUN creates a layer. Combining commands reduces layers, but too much combining hurts caching:

# Bad: one huge layer, any change rebuilds everything
RUN apt-get update && \
    apt-get install -y curl git build-essential && \
    npm ci && \
    npm run build

# Good: group by change frequency
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl git && \
    rm -rf /var/lib/apt/lists/*

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

Group system dependencies together since they rarely change. Keep application dependency installation and build steps separate since they change at different rates.

Measuring Cache Effectiveness

Use --progress=plain to see which steps are cached:

docker build --progress=plain -t myapp . 2>&1 | grep -E "CACHED|RUN"

Cached steps show CACHED in the output. If a step you expected to be cached is running, check what changed above it in the Dockerfile.

Check build times:

time docker build -t myapp .

Summary

Docker layer caching is the most effective way to speed up builds. The strategies in priority order: reorder instructions so dependencies are copied before source code, use cache mounts for package managers, maintain a thorough .dockerignore, and set up registry-based caching for CI pipelines. A well-cached build that takes 10 minutes uncached can drop to under 30 seconds on subsequent runs.