How We Cut Docker Build Times from 12 Minutes to 90 Seconds

Programming
Date:October 8, 2026
Topic:
How We Cut Docker Build Times from 12 Minutes to 90 Seconds
⏱ 3 min read

Twelve minutes. That's how long our team stared at a spinning CI spinner every time we pushed a change to our monorepo. Twelve minutes of context switching, Slack scrolling, and pretending to review PRs while the Docker daemon churned through layers it had built a hundred times before. We didn't accept it as the cost of doing business. We treated it like a bug.

The Baseline: What We Were Doing Wrong

Our Dockerfile was a textbook example of cargo-culting. We copied the entire monorepo context before installing dependencies. We ran npm install in a single stage for every service. We ignored .dockerignore. The result: no layer caching between builds, massive context transfer over the network, and zero parallelism.

dockerfile
# BEFORE: The 12-minute offender
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm ci --workspaces --if-present
RUN npm run build --workspaces
CMD ["node", "apps/api/dist/main.js"]
⚠️
WarningCOPY . . invalidates the cache on any file change. A typo in a README triggers a full dependency reinstall.

Step 1: Shrink the Build Context

The fastest bytes to transfer are the ones you never send. We audited .dockerignore like it was a security policy. Node modules, git history, local env files, test coverage reports, IDE folders — all gone. Context size dropped from 2.1 GB to 45 MB. Build upload time went from 90 seconds to 3.

text
# .dockerignore
node_modules
.git
dist
build
*.log
.env*
.vscode
.idea
coverage
*.md
!README.md

Step 2: Leverage Layer Caching Ruthlessly

Docker caches layers in order. If step 3 changes, steps 4+ rerun. We reordered the Dockerfile to copy only package manifests first, install deps, then copy source code. A source change no longer triggers npm ci.

dockerfile
# AFTER: Cache-friendly order
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
COPY apps/api/package*.json ./apps/api/
COPY packages/*/package*.json ./packages/
RUN npm ci --workspaces --if-present --prefer-offline

FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build --workspaces

FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "apps/api/dist/main.js"]

Step 3: Enable BuildKit and Parallelism

Classic Docker builds are sequential. BuildKit runs independent stages concurrently. We added DOCKER_BUILDKIT=1 to CI and restructured stages so deps, lint, and test stages run in parallel. The DAG scheduler handles the rest.

💡
TipSet BUILDKIT_PROGRESS=plain in CI logs to see the parallel execution graph.

Step 4: Cache Mounts for Package Managers

Even with layer caching, npm ci re-extracts tarballs. BuildKit's --mount=type=cache persists the global npm cache across builds. Cold builds stay fast; warm builds become instant.

dockerfile
# Cache mount example
RUN --mount=type=cache,target=/root/.npm \
    npm ci --workspaces --if-present --prefer-offline

Step 5: Multi-Stage for Production Slimness

The final image dropped from 1.2 GB to 180 MB. We copy only dist and production node_modules into a distroless base. Smaller images pull faster, deploy faster, and have a smaller attack surface.

MetricBeforeAfterImprovement
Build Time12 min 03 sec1 min 28 sec88% faster
Context Size2.1 GB45 MB98% smaller
Final Image1.2 GB180 MB85% smaller
CI Cost/Month$1,450$18087% cheaper

The CI Configuration That Ties It Together

yaml
# .github/workflows/docker.yml
jobs:
  build:
    runs-on: ubuntu-latest
    env:
      DOCKER_BUILDKIT: 1
      BUILDKIT_PROGRESS: plain
    steps:
      - uses: actions/checkout@v4
      - name: Build with BuildKit
        run: |
          docker buildx build \
            --cache-from type=gha \
            --cache-to type=gha,mode=max \
            --target runner \
            -t myapp:latest .
ℹ️
NoteGitHub Actions cache backend (type=gha) persists BuildKit cache between workflow runs for free.

Your 15-Minute Action Plan

Don't rewrite everything. Pick one service. Add .dockerignore. Reorder the Dockerfile for cache. Enable BuildKit. Measure. Repeat. The compounding returns are real: our team saves 400+ engineering hours per month. That's not infrastructure work. That's product velocity.


✦
"

The best optimization is deleting code. The second best is deleting wait time.

— Platform Team Lead
Share𝕏 Twitterin LinkedInin Whatsapp