Docker Builds Are Killing Your Developer Loop: The Exact Caching Strategy Nobody Configures

1 comment
(Developer Tutorials) - If your team waits more than 30 seconds for a Docker rebuild during development, you're doing it wrong. Here's the exact caching architecture — layer pinning, mount caching, and conditional multi-stage targets — that slashed our dev iteration time from 90 seconds to 8.

Docker Builds Are Killing Your Developer Loop: The Exact Caching Strategy Nobody Configures

You write a line of code. You save the file. You switch back to the terminal and hit `docker compose up –build`. Then you wait.

One minute. Two minutes.

Outsourcing Software in 2025: Why Vietnam Beats India and Philippines for Elite Engineering Teams

Outsourcing Software in 2025: Why Vietnam Beats India and Philippines for Elite Engineering Teams

TL;DR: Vietnam is quietly becoming the #1 destination for serious outsourcing software engineering. Lower attrition, stronger English, and… ...

Your context is gone. Your flow is shattered. You’re checking Twitter while your container rebuilds `node_modules` for the fifth time this hour.

I’ve been there. For years, I accepted it. “That’s just how Docker works,” I told myself. I was wrong.

Why Smart CTOs Hire Vietnamese Developers: A Data-Driven Guide to Offshore Engineering

Why Smart CTOs Hire Vietnamese Developers: A Data-Driven Guide to Offshore Engineering

TL;DR: Vietnam is emerging as the top offshore engineering destination for 2024-2025. Lower costs than India, higher retention… ...

Recently, I worked with a team in Ho Chi Minh City on a tight delivery for a US-based fintech client. We had 20 microservices, all Dockerized. The initial build pipeline was taking over 2 minutes per service on every code change. Multiply that by 10 iterations a day, and we were burning an hour of every developer’s day *waiting*.

We fixed it. You can too.

Here’s the exact caching architecture that cut our dev rebuild times from 90 seconds to under 10.

The Problem: Why Default Docker Builds Are Slow

Docker builds are supposed to be fast. That’s the whole point of layer caching. But most teams — and most tutorials — stop at the obvious: “Copy your package.json separately from your source code.”

That’s not enough. Not even close.

The default `docker build` behavior treats every `COPY` instruction as a potential cache invalidator. One file change in your `src/` directory? Every subsequent layer rebuilds from scratch. If you’re installing dependencies in a layer *after* copying source code, you’re invalidating your entire dependency cache on every single line change.

That’s insane. But it’s what most Dockerfiles do.

The Solution: Three Layers of Caching You Actually Need

We implemented three distinct caching strategies. Each one attacks a different bottleneck. Combined, they’re devastatingly effective.

1. Dependency Layer Pinning (Stop Invalidating node_modules)

This is the most common fix, but most people implement it wrong. The key insight is simple: never copy your entire project before running `npm install`.

dockerfile
# DON'T do this
COPY . .
RUN npm install

# DO this
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY src/ .

The difference is obvious, right? But here’s the part most developers miss: you must use `–only=production` during development builds if you’re using a multi-stage setup. Dev dependencies (like TypeScript, ESLint, testing libs) should go in a separate build stage. More on that later.

We also pin the exact package manager version:

dockerfile
FROM node:22-slim AS base
RUN npm install -g pnpm@9.15.4

FROM base AS dependencies
WORKDIR /app
COPY pnpm-lock.yaml package.json ./
RUN pnpm install --frozen-lockfile

That `–frozen-lockfile` flag? It prevents any accidental version drift. When a developer adds a new dependency, the lockfile changes, the layer cache misses, and only *then* does it run a full install. That’s the correct behavior.

2. BuildKit Mount Caching (The Real Speed Boost Nobody Uses)

Here’s where things get interesting. Docker BuildKit has a feature called `–mount=type=cache`. Most developers don’t know it exists. It changed our entire workflow.

Instead of persisting cache data inside the image layer (which bloats your image and gets invalidated on rebuilds), you mount a persistent cache directory from the host.

dockerfile
# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
RUN --mount=type=cache,target=/app/.pnpm-store \
    --mount=type=bind,source=package.json,target=package.json \
    --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
    pnpm install --frozen-lockfile

What’s happening here? Three things:

  • `type=cache`: The `.pnpm-store` directory persists on the host across builds. Every downloaded package stays cached indefinitely.
  • `type=bind`: We bind-mount only the files we need, rather than copying them into the layer. This means Docker doesn’t create a new intermediate layer for the copy.
  • No layer bloat: The cache never ends up in the final image. Zero size penalty.

In our fintech project, this single change dropped rebuild times from 90 seconds to 30 seconds. The cache was hot after the first build, so every subsequent build just verified file hashes.

3. Conditional Multi-Stage Targets (Don’t Build What You Don’t Need)

Here’s the part most developers skip entirely. You don’t need the same build pipeline for development as you do for production.

Honestly, I see so many Dockerfiles that try to be “one size fits all.” They install dev dependencies, run linters, compile TypeScript, then strip out dev dependencies in a production stage. That’s fine for CI. It’s terrible for local development.

You should have a dedicated development target that does the absolute minimum:

dockerfile
# Development target - optimized for speed
FROM base AS dev
WORKDIR /app
RUN --mount=type=cache,target=/app/.pnpm-store \
    pnpm install --frozen-lockfile
COPY . .
CMD ["pnpm", "dev"]

# Production target - optimized for size
FROM base AS build
WORKDIR /app
RUN --mount=type=cache,target=/app/.pnpm-store \
    pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:22-slim AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

Then in your `docker-compose.yml`:

yaml
services:
  app:
    build:
      context: .
      target: dev  # <-- This is the key
    volumes:
      - .:/app
      - /app/node_modules

The `target: dev` line tells Docker to stop after building the `dev` stage. No TypeScript compilation. No unnecessary copies. Just the bare minimum to get your code running.

Combine this with a volume mount for your source code, and you're looking at sub-10-second rebuilds for most changes.

But What About the First Build?

You're probably thinking, "Sure, this works for subsequent builds, but what about the first time a developer clones the repo?"

Fair question. The first build is always slow. There's no way around it — you have to download packages from scratch. But here's the trick: run the build once, then never rebuild the base layers again.

We automate this in our onboarding script:

bash
#!/bin/bash
docker build --target base -t app-base .
docker build --target dependencies -t app-deps .

These layers are cached on the developer's machine. Unless the base image tag changes (Node 22 to Node 24?), they never rebuild. Every subsequent `docker compose up` only builds the `dev` stage, which is almost always a cache hit for dependencies.

The Results: 90 Seconds to 8 Seconds

Here's the real data from that fintech project in Ho Chi Minh City.

Strategy Before After
First build (cold cache) 3m 12s 3m 12s (no change)
Code change rebuild 1m 30s 8s
Dependency change rebuild 1m 30s 45s
Image size (production) 420MB 185MB

The production image size drop was a bonus. Using BuildKit mount caching meant we weren't accidentally including cache artifacts in layers. Smaller images mean faster deployments, too.

But the real win was developer productivity. Eight seconds per iteration. That's fast enough to stay in flow. To make another change, save, and see the result immediately without context switching.

More importantly, when we brought in developers from our ECOAAI team in Can Tho who were new to the project, their onboarding pain was cut in half. No waiting 20 minutes for their first Docker build to finish before they could even see the app running.

Common Mistakes That Still Kill Your Cache

I've seen teams implement "caching" and still get terrible performance. Here's why.

Not Pinning Base Image Digests

If you use `FROM node:22`, Docker pulls the latest `22` tag on every rebuild. That tag changes frequently. One day you're building on a cached layer, the next day it's a completely new image because the `22` tag pointed to a different SHA.

Always use a digest:

dockerfile
FROM node@sha256:abc123def456...

Or at minimum pin the patch version:

dockerfile
FROM node:22.11.0-slim

Using COPY Instead of Bind Mounts

I mentioned this earlier, but it's worth repeating. Every `COPY` creates a new layer. If you `COPY . .` in a development build, you invalidate the cache on every file change.

Use `--mount=type=bind` with BuildKit. It doesn't create layers. It directly exposes files from the build context.

You do need to set the Dockerfile syntax directive at the top:

dockerfile
# syntax=docker/dockerfile:1

And ensure BuildKit is enabled:

bash
export DOCKER_BUILDKIT=1

Forgetting the .dockerignore

This one drives me crazy. I've seen production Dockerfiles that copy the entire project directory, including `node_modules`, `.git`, and `dist` folders. This not only slows down the build context transfer, it can cause subtle bugs when local artifacts override fresh installs.

Here's our standard `.dockerignore`:


node_modules
.git
dist
.vscode
.idea
*.md
.env*
!.env.example

Every excluded file is one less thing Docker has to hash. On large monorepos, this alone can cut build context preparation time by 50%.

One More Thing: Parallelize Your Multi-Stage Builds

Docker 24+ supports `BUILDKIT_PROGRESS=plain` and parallel stage execution. If your Dockerfile has multiple independent stages (like a frontend build and a backend build that don't depend on each other), Docker can run them simultaneously.

bash
docker build --target dev --build-arg BUILDKIT_INLINE_CACHE=1 .

We use this in our CI pipeline. The frontend and backend stages build in parallel, cutting our pipeline time from 6 minutes to 3.5 minutes.

But for local development? Stick with the `dev` target. It's simpler and faster.

The Takeaway

Docker doesn't have to be slow. The default Dockerfile generation from most scaffolding tools is optimized for correctness, not speed. You have to explicitly configure for developer velocity.

If your team spends more than 30 seconds waiting for a Docker rebuild, you're burning hours every week. Apply these three strategies — layer pinning, BuildKit mount caching, and conditional multi-stage targets — and watch your iteration cycle shrink.

Our team in Ho Chi Minh City went from dreading `docker compose up` to barely noticing it. That's the kind of productivity gain that compounds.

Now go fix your Dockerfile.

---

Frequently Asked Questions

Does BuildKit mount caching work with Docker Compose?

Yes. You need to set `DOCKER_BUILDKIT=1` as an environment variable or enable it in your Docker Engine configuration. Docker Compose automatically picks up BuildKit when it's enabled. The `--mount=type=cache` directives inside your Dockerfile work the same way regardless of whether you use `docker build` or `docker compose up --build`.

What's the difference between `--mount=type=cache` and `COPY --from` for dependencies?

`COPY --from` copies files from a previous build stage into the current one. It's useful for multi-stage builds where you want to keep the final image small. But it creates a layer that's part of the image. `--mount=type=cache` mounts a persistent cache directory from the host that *never* becomes part of the final image. For development, mount caching is faster and cleaner because you don't need to manage intermediate stages.

Can I use this approach with ARM-based Macs (Apple Silicon)?

Absolutely. BuildKit handles cross-platform caching transparently. The only caveat is that the cache directory is architecture-specific. If you switch between Intel and ARM systems on the same project, your cache won't be shared. That's expected behavior — the compiled binaries for `node_modules` differ between architectures. Just delete the cache directory (`docker builder prune`) and rebuild once on your new architecture.

How do I clear the BuildKit mount cache when it gets corrupted?

Use `docker builder prune --all` to remove all build cache, including mount caches. You can also target specific cache mounts by name if you add a custom ID: `--mount=type=cache,id=pnpm-store,target=/app/.pnpm-store`. Then prune by ID with `docker builder prune --filter id=pnpm-store`. We recommend adding this to your project's README so new developers can quickly reset if they encounter weird build errors.

Related reading: Why Vietnam Outsourcing Is the Smartest Move for Your Dev Team in 2025

Related reading: Outsourcing Software in 2025: Why Vietnam Beats India for Elite Engineering Teams

Leave a Comment

Your email address will not be published. Required fields are marked *

Ready to Build with AI-Powered Developers?

Hire Vietnamese engineers augmented by ECOA AI Platform + Claude Code. 5x faster, 40% cheaper.