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.
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.
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 .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.
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.
# 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.
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:
# 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:
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:
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.
The Checklist
Before you push your next Dockerfile:
- Layer order — dependency manifests copied and installed before source code?
- .dockerignore — excludes
node_modules,.git, and other noise? - Multi-stage — build tools excluded from the final image?
- Base image — using
-alpineor-slimvariants? - RUN consolidation — related commands chained with
&&? - CI caching — BuildKit cache mounts or registry caching enabled?
Get these right and most builds finish in under 30 seconds.