0x55aa
← Back to Blog

Docker Layer Caching: Turn 8-Minute Builds Into 30-Second Ones 🚀🐳

6 min read

Docker Layer Caching: Turn 8-Minute Builds Into 30-Second Ones 🚀🐳

Picture this: You fix a one-line typo in a README. You push to GitHub. Your CI pipeline fires up and spends 8 minutes reinstalling all 347 npm packages — the exact same packages it installed yesterday, and the day before that.

Meanwhile, you're watching the progress bar like it owes you money.

The crime? A Dockerfile that doesn't understand how layer caching works.

The sentence? Eternal waiting, mounting AWS bills, and a team that dreads every deployment.

Let's fix this. Right now.

How Docker Layer Caching Actually Works 🧅

Docker builds images in layers — think of it like a stack of transparent slides. Each instruction in your Dockerfile creates a new layer. The magic is that Docker caches each layer, and if nothing has changed since last time, it reuses the cached version instead of rebuilding it.

The catch? Caching is sequential and greedy. The moment Docker detects ANY change in a layer, it throws away the cache for that layer AND every layer below it. Like knocking over dominoes — one change cascades down and rebuilds everything after it.

So the golden rule is:

Put things that change rarely at the TOP. Put things that change often at the BOTTOM.

Sounds obvious. Almost nobody does it instinctively at first.

The Classic Mistake That Ruins Everything 💥

Here's a real-world Node.js Dockerfile I once inherited. See if you can spot the problem:

# ❌ The "why is this so slow" Dockerfile
FROM node:20-alpine

WORKDIR /app

# Copy ALL source code first
COPY . .

# THEN install dependencies
RUN npm ci

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

Every. Single. Build. Reinstalls all packages from scratch. Why? Because COPY . . copies your source files, and your source files change with every commit. Cache busted. npm ci runs every time. 8 minutes every time.

Here's the fixed version:

# ✅ The "wow that was fast" Dockerfile
FROM node:20-alpine

WORKDIR /app

# Copy ONLY the dependency manifest first (changes rarely)
COPY package.json package-lock.json ./

# Install dependencies — this layer gets cached until package*.json changes
RUN npm ci

# NOW copy source code (changes often, but only invalidates layers below deps)
COPY . .

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

The build logic is identical. The result is completely different. The first time you build, Docker runs npm ci and caches that layer. Every subsequent build, if package.json hasn't changed, Docker skips the install entirely and jumps straight to copying your source code. A 30-second build instead of 8 minutes.

I've seen this single change reduce CI build times by over 80% on teams where nobody had questioned the original Dockerfile.

Level Up: Multi-Stage Builds With Cached Layers 🏗️

Here's where it gets really good. Combine layer ordering with multi-stage builds for production-ready performance:

# ✅ Production-ready Dockerfile with smart caching
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Grab only what you need from previous stages
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

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

Three stages: one for production deps, one for building, one lean final image. The cache layer ordering is respected in each stage. Your final image contains no dev dependencies, no build tools, no source code — just the compiled output and what's needed to run it.

The production image ends up tiny. Deploys are fast. Life is good.

Real Lessons Learned the Hard Way 🔥

Lesson 1: Your .dockerignore is as important as your Dockerfile. If you forget to add node_modules to .dockerignore, Docker copies your local node_modules into the container before running npm ci. The COPY . . layer will invalidate on every build and your carefully ordered layers won't matter. Always create .dockerignore first — at minimum: node_modules, .git, *.log, .env.

Lesson 2: Lock files are your friends. Copy both package.json AND package-lock.json (or yarn.lock, pnpm-lock.yaml). Only copying package.json means a floating version in a dependency can change without touching your lock file, causing different builds at different times. Copy the lock file. Pin to exact versions. Sleep better.

Lesson 3: COPY is greedy about timestamps. On Linux, if a file's modification timestamp changed but its content didn't, Docker still busts the cache for that layer. On some CI systems, checking out a fresh git repo resets timestamps on every file, making every COPY layer miss cache. The fix? git config core.checkoutAtomically true or use --cache-from to pull a pre-built image to compare against.

Lesson 4: BuildKit changes everything. Enable Docker BuildKit (DOCKER_BUILDKIT=1) if you're still on an older Docker setup. It enables parallel stage building, inline cache hints, and more intelligent cache management. In modern Docker it's on by default — but in some CI environments you may need to enable it explicitly.

The Payoff in GitHub Actions 📊

Here's a quick before/after from a real project after applying these fixes:

Step Before After
npm ci 4m 12s 8s (cached)
npm run build 1m 45s 1m 40s
Docker push 2m 30s 45s (layer reuse)
Total ~8.5 min ~2.5 min

That's 70% faster. Across 20 deploys a day, that's 2 hours of CI time saved daily. That's real money if you're paying for CI minutes — and real sanity if you're a developer watching a progress bar.

TL;DR: The Rules 📋

  1. Order matters: rare changes first, frequent changes last
  2. Split dependency install from code copy: always copy manifest → install → copy source
  3. Use .dockerignore: don't let local junk bust your cache
  4. Lock your lock files: copy both package.json and package-lock.json
  5. Combine with multi-stage builds for both fast builds AND slim images
  6. Enable BuildKit: if you haven't already, do it now

Your developers will thank you. Your CI bill will thank you. Future you — staring at a 30-second build instead of a 9-minute one — will absolutely thank you.


Got a Dockerfile horror story? Drop it in the comments — I want to hear the worst offenders. And if this saved your pipeline, share it with the teammate who wrote your current Dockerfile. Gently. We were all there once. 😄