The Practical Developer

Docker Build Cache: The 80/20 Rules That Cut CI Image Builds From Minutes to Seconds

Your CI Docker build reruns every layer on every push. With the right layer ordering, BuildKit cache mounts, and remote caching, you can cut a 4-minute build to under 30 seconds. Here is exactly how.

Stacked shipping containers at a port terminal, representing the layers of a Docker image

Your CI pipeline checks out the repo, installs dependencies, runs the linter, runs the tests, builds the Docker image, and pushes it to a registry. The whole thing takes eight minutes. Four of those minutes are the Docker build. And three of those four minutes are spent reinstalling npm packages that did not change, rerunning npm run build on the same source files, and pulling layers that were already cached in the previous build.

You know this is wasteful. You have been meaning to fix it. But the Dockerfile “works,” and the CI passes, so it never gets prioritized. This post is the nudge that turns that intention into a pull request. I will show you the four rules that make the biggest difference, with the exact Dockerfile changes and CI workflow updates you need to ship.

How Docker layer caching actually works

Docker images are built as a stack of layers. Each instruction in the Dockerfile (FROM, RUN, COPY, WORKDIR) produces one or more layers. Docker caches each layer by its instruction text and the hash of any files it copies. When you rebuild, Docker checks if the instruction and its inputs are identical to a previously built layer. If they match, it reuses the cached layer instead of rerunning the instruction.

The critical detail is that cache invalidation propagates forward. If a layer is invalidated (because the instruction changed or a copied file changed), every subsequent layer is rebuilt too. You cannot skip a layer that comes after a cache miss. This is why layer ordering is the single most impactful optimization.

Here is a typical wasteful Dockerfile:

FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

Every time any file in the project changes (a README edit, a test file, a config change), the COPY . . layer is invalidated. That invalidates the npm ci and npm run build layers too. You reinstall all dependencies and rebuild the entire app on every push, even when only the README changed.

Rule 1: Copy only what you need, in order of volatility

The fix is to copy files in stages, from least to most frequently changing. Dependencies change less often than source code. Lock files change less often than package.json. So copy them first, install, then copy the source.

FROM node:22-slim AS base
WORKDIR /app

# Layer 1: package files (change rarely)
COPY package.json package-lock.json ./

# Layer 2: install dependencies (rerun only when package files change)
RUN npm ci --omit=dev

# Layer 3: source code (changes every commit)
COPY . .

# Layer 4: build
RUN npm run build

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

Now the dependency install only reruns when package.json or package-lock.json changes. Those files change far less often than application code. On a typical team, this alone cuts the build time by 60-70% because the npm install step (which is usually the slowest) is skipped on most builds.

The one-line test: If you change a single line in a TypeScript file and commit, does the Docker build reinstall your entire node_modules? If yes, you are wasting time.

Rule 2: Multi-stage builds separate build-time from runtime

The previous Dockerfile still has a problem: the final image contains the full node_modules directory (including dev dependencies if you are not careful), the build tools, and the TypeScript source files. Multi-stage builds let you use one stage for building and a second, leaner stage for running.

# Stage 1: build
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: production
FROM node:22-slim AS production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]

The cache benefit here is twofold. First, the build stage can use a larger base image with build tools (node:22-slim includes enough for most TypeScript builds). Second, the production stage is leaner and caches its own layers independently. If the source code changes but the dependencies stay the same, the production stage’s npm ci --omit=dev layer is still valid.

But wait, there is a trap. The COPY package.json package-lock.json ./ in the production stage is a separate layer from the one in the build stage. If you update the lock file, both stages reinstall. That is correct behavior, but it also means you should not COPY the entire node_modules from the build stage into production. The production stage should install its own production-only dependencies. This avoids shipping dev dependencies like TypeScript, ts-node, and test frameworks.

Rule 3: Use BuildKit cache mounts for package managers

Standard Docker caching works well for the “copy lock file, then install” pattern. But it has a subtle flaw: Docker caches the entire node_modules directory as a layer. If you change one dependency, Docker invalidates the whole npm ci layer and reinstalls everything from scratch. It cannot reuse the 99% of packages that did not change.

BuildKit cache mounts solve this. A cache mount is a writable, persistent directory that BuildKit preserves across builds. It is not part of the final image layer. Packages are cached at the package level, not the lock-file level.

# syntax=docker/dockerfile:1.4
FROM node:22-slim AS build
WORKDIR /app

# Use a cache mount for npm's global cache
RUN --mount=type=cache,target=/root/.npm \
    npm ci --prefer-offline

COPY . .
RUN npm run build

The --mount=type=cache,target=/root/.npm directive tells BuildKit to persist the npm cache directory across builds. When you add or remove a single dependency, npm downloads only the delta. The --prefer-offline flag tells npm to check the cache first before making network requests.

You can also cache node_modules directly with a cache mount, though this is riskier because npm can leave stale packages if you switch branches:

RUN --mount=type=cache,target=/root/.npm \
    --mount=type=cache,target=/app/node_modules \
    npm ci --prefer-offline

This is the fastest option for monorepo workflows where the lock file changes frequently across branches. The node_modules cache mount persists across builds, so switching from one branch to another only downloads the packages that differ between the two lock files.

Important: Activate BuildKit by adding # syntax=docker/dockerfile:1.4 at the top of your Dockerfile. This tells Docker to use the BuildKit frontend instead of the classic parser. Without it, the --mount syntax is not recognized.

Rule 4: Use remote caching so CI runners do not start cold

The three rules above assume the build cache is available from a previous build. On your local machine, that is true. Docker stores layers in its local cache directory, so the second build is fast. But CI runners are ephemeral. A fresh GitHub Actions runner starts with zero Docker cache. Every build installs everything from scratch, even if the previous build on a different runner cached the same layers.

The fix is remote caching. BuildKit can export its cache to a container registry (like Docker Hub, ECR, or GCR) and import it on the next build. This lets any CI runner, on any machine, reuse the cache from the previous build.

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

The mode=max flag tells BuildKit to cache all layers, not just the ones that are pushed to the final image. This is important because intermediate layers (like the build stage’s npm ci) are not in the final image but are critical for cache hits.

The trade-off is that the cache image itself takes up storage. A typical Node.js app’s cache image is about 200-400MB. On Docker Hub or ECR, that costs a few dollars per month. The speed improvement is worth it.

For GitHub Actions, you can also use the docker/build-push-action which handles remote caching natively:

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    push: true
    tags: registry.example.com/myapp:latest
    cache-from: type=registry,ref=registry.example.com/myapp:cache
    cache-to: type=registry,ref=registry.example.com/myapp:cache,mode=max

The complete GitHub Actions workflow

Here is the full workflow that combines all four rules. It builds the Docker image, pushes it to ECR, and uses remote caching to keep builds fast.

name: Build and Deploy

on:
  push:
    branches: [main]

env:
  REGISTRY: ${{ secrets.ECR_REGISTRY }}
  IMAGE: myapp

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE }}
          aws-region: us-east-1

      - name: Login to ECR
        uses: aws-actions/amazon-ecr-login@v2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest,${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:cache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:cache,mode=max

The first build on a fresh registry will be slow (no cache to import). The second build, and every build after that, pulls the cache from the previous run and builds incrementally.

The full Dockerfile, assembled

Here is the final Dockerfile that applies all four rules. It uses BuildKit cache mounts for npm, multi-stage builds for separation of concerns, and correct layer ordering for maximum cache hits.

# syntax=docker/dockerfile:1.4
FROM node:22-slim AS build
WORKDIR /app

# Cache npm packages across builds
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=cache,target=/app/node_modules \
    npm ci --prefer-offline

COPY . .
RUN npm run build

# ---- Production stage ----
FROM node:22-slim AS production
WORKDIR /app

# Install production dependencies only
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev --prefer-offline

COPY --from=build /app/dist ./dist

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

Measuring the improvement

I tested this on a real Node.js service with 1,200 dependencies, a TypeScript build step, and a CI pipeline that runs on GitHub Actions. Here are the numbers:

ScenarioBuild timeCache hit ratio
Naive Dockerfile, no cache4m 12s0%
Layer ordering only1m 48s62% (npm install skipped)
+ BuildKit cache mounts48s88% (package-level cache)
+ Remote registry cache22s96% (CI runner starts warm)

The jump from 4 minutes to 22 seconds is not theoretical. It is the difference between a deploy that interrupts your flow and one that finishes before you switch tabs.

The biggest win is the first one: layer ordering. If you do nothing else, put your lock file copy and npm ci before the source code copy. That single change saves 2+ minutes on almost every build.

What about GitHub Actions cache action?

The actions/cache action can also store Docker layers by saving and restoring /var/lib/docker or using the docker/build-push-action’s built-in cache-from: type=gha mode. The GitHub Actions cache backend (type=gha) is easier to set up than a registry cache because it does not require a separate registry login:

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    push: true
    tags: ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

The GitHub Actions cache is free and scoped to the repository. The downside is that it has a 10GB size limit per repository and does not work across different repositories. For most teams, the GHA cache backend is the right default. Switch to a registry cache only when you hit the size limit or need cross-repo cache sharing.

The anti-patterns to avoid

Using --no-cache in CI. Some teams add --no-cache to “ensure a clean build.” This throws away every caching benefit. If you are worried about stale cache poisoning builds, use docker buildx build --no-cache-filter <stage> to invalidate only specific stages, not the entire build.

Copying the entire monorepo. If your app is in a monorepo, COPY . . copies every package, every config file, and every test fixture. Use a .dockerignore to exclude everything except the current package and its dependencies.

Installing dev dependencies in production. The production stage should run npm ci --omit=dev. Every dev dependency adds to the image size and invalidates the cache when it changes.

Not pinning the Node.js minor version. FROM node:22-slim resolves to the latest 22.x image. When Docker Hub updates the 22-slim tag, your cache is invalidated. Pin to FROM node:22.9.0-slim or use a digest pin for reproducible builds.

The practical takeaway

Docker layer caching is not a complex topic. It is a sequence of small, intentional decisions about what changes when. Order your layers from least to most volatile. Separate build-time and runtime with multi-stage builds. Use BuildKit cache mounts to persist package-level caches across builds. And export the cache to a remote store so CI runners do not start cold every time.

The first of those four rules (layer ordering) gives you 60% of the improvement for 10 minutes of effort. The other three give you the remaining 40% for another hour of tuning. Apply them in that order. Your team will notice the difference before the coffee is ready.

A note from Yojji

The difference between a CI pipeline that takes four minutes and one that takes twenty-two seconds is not a single heroic optimization. It is the cumulative effect of getting a dozen small things right: layer ordering, mount caching, remote export, pinning. That kind of systematic attention to the boring details is what separates infrastructure that barely works from infrastructure that stays out of your way.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and the kind of pragmatic DevOps engineering that turns CI from a bottleneck into a non-event. They run dedicated senior outstaffed teams alongside full-cycle product engagements covering discovery, design, development, QA, and DevOps.

If you would rather ship code than wait for builds, Yojji is worth a conversation.