All Posts

Why Your Docker Builds Are Slow (And How to Fix Them)

Docker builds don't have to take forever. Learn the layer caching strategies, multi-stage patterns, and .dockerignore tricks that can cut your build times by 10x.

Ashis
·
May 16, 2026
·
5 min read
·
Why Your Docker Builds Are Slow (And How to Fix Them)

If your Docker builds take more than a minute, something is wrong. Most slow builds come down to three things: broken layer caching, bloated contexts, and unnecessary dependencies. Let's fix all three.

How Docker Layer Caching Works

Docker builds images layer by layer, top to bottom. Each instruction in your Dockerfile creates a layer. If a layer hasn't changed since the last build, Docker reuses the cached version instead of rebuilding it.

The catch: if any layer changes, every layer after it is rebuilt too. This is why instruction order matters so much.

dockerfile
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci

In the example above, changing any file — even a README — invalidates the COPY . . layer, which forces npm ci to run again from scratch.

dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

Now npm ci only reruns when package.json or package-lock.json actually change. For most builds, this alone saves minutes.

The same pattern applies to every language. Copy dependency manifests first (`requirements.txt`, `go.mod`, `Gemfile.lock`, `pom.xml`), install, then copy the rest.

The .dockerignore File

Every time you run docker build, Docker sends the entire build context (your project directory) to the daemon. Without a .dockerignore, that includes node_modules, .git, test fixtures, and every other file you don't need in the image.

text
node_modules
.git
.env*
*.md
dist
coverage
.next

On a typical Node.js project, this can reduce the build context from 500MB+ to under 5MB. That's not just faster transfers — it means fewer cache invalidations too, since irrelevant file changes won't trigger rebuilds.

Multi-Stage Builds

Multi-stage builds let you use one image for building and a different (smaller) image for running. Your final image only contains what it needs to run — no compilers, no dev dependencies, no build tools.

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

# Stage 2: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]

The builder stage has everything — TypeScript, ESLint, all dev dependencies. The runner stage only has production dependencies and the compiled output. Result: smaller images, faster deploys, smaller attack surface.

Multi-stage builds are especially powerful for compiled languages like Go, Rust, and Java where the final image can use `scratch` or `distroless` — no OS, no shell, just your binary.

Dependency Caching in CI

In CI environments (GitHub Actions, GitLab CI, etc.), Docker layer cache is often empty because each run starts fresh. Two ways to fix this:

BuildKit Cache Mounts

Docker BuildKit supports cache mounts that persist package manager caches across builds:

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

The --mount=type=cache keeps the npm cache between builds, so even if the layer is invalidated, packages that haven't changed are pulled from cache instead of re-downloaded.

Registry-Based Caching

Push your build cache to a registry so CI runners can pull it:

bash
docker buildx build \
  --cache-from type=registry,ref=myregistry/myapp:cache \
  --cache-to type=registry,ref=myregistry/myapp:cache,mode=max \
  -t myapp:latest .

Common Mistakes

Mistake Impact Fix
COPY . . before RUN npm ci Deps reinstall on every code change Copy manifests first
No .dockerignore Huge build context, slow transfers Add one immediately
apt-get install without --no-install-recommends Installs unnecessary packages Always use the flag
Separate RUN for each apt-get command Extra layers, larger image Chain with &&
Not using multi-stage builds Dev tools in production image Split build and run stages

Measuring Build Performance

Always measure before optimizing. Docker has built-in timing:

bash
DOCKER_BUILDKIT=1 docker build --progress=plain -t myapp . 2>&1 | tee build.log

The --progress=plain flag shows each step with timing. Look for the slowest steps — those are your optimization targets.

Don't optimize blindly. A 30-second build that runs once a day doesn't need the same attention as a 5-minute build that runs on every push. Focus your effort where it actually saves developer time.

The Checklist

Before you push your next Dockerfile:

  1. Layer order — dependency manifests copied and installed before source code?
  2. .dockerignore — excludes node_modules, .git, and other noise?
  3. Multi-stage — build tools excluded from the final image?
  4. Base image — using -alpine or -slim variants?
  5. RUN consolidation — related commands chained with &&?
  6. CI caching — BuildKit cache mounts or registry caching enabled?

Get these right and most builds finish in under 30 seconds.