Optimizing Docker Image Layers
Last month a team's Node API image was 1.8 GB and their CI built it from scratch in eleven minutes on every push. The Dockerfile was "fine" — multi-stage, non-root user, health check — but layer ordering was wrong. COPY . . sat above RUN npm ci, so a one-line README change invalidated the entire dependency layer. Reordering three instructions dropped rebuild time to ninety seconds on cache hit and cut the final image to 420 MB. Layer optimization is not micro-optimization; it is the difference between CI that scales and CI that queues.
How Docker layer caching works
Each Dockerfile instruction produces an immutable layer identified by the instruction text plus the content hash of its inputs. Change anything upstream and every downstream layer rebuilds.
The rule I follow: order from least frequently changing to most frequently changing:
- Base image and OS packages
- Language runtime and tool installation
- Dependency lockfiles and dependency install
- Application source code
- Build and compile steps
FROM node:20-bookworm-slim AS deps
WORKDIR /app
# Lockfiles change rarely — cache npm ci across source edits
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-bookworm-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
npm ci runs only when package-lock.json changes. Daily code commits hit cached dependency layers.
COPY scope and .dockerignore
Every file in the build context can invalidate COPY layers. A .dockerignore that excludes node_modules, .git, *.md, test fixtures, and local .env files prevents accidental cache busts and shrinks context upload time.
Use explicit COPY paths instead of blind COPY . . when possible:
COPY src/ src/
COPY tsconfig.json package.json ./
Smaller COPY inputs mean narrower cache invalidation and faster builds on remote builders.
RUN layer hygiene
Package manager caches belong in the same RUN layer as the install, with cleanup:
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Splitting install and cleanup across RUN instructions leaves the apt lists in an earlier layer — still counted in image size even if a later layer deletes them.
For BuildKit cache mounts, persist package manager caches across builds without bloating the final image:
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
The mount lives outside image layers; dependencies still copy into the image only where you explicitly COPY or RUN without the mount.
Measuring with dive and history
Install dive and inspect your image:
dive myapp:latest
Dive shows cumulative size per layer and lets you drill into added/modified/deleted files. Look for layers that duplicate large directories — copying node_modules twice, leaving build artifacts in final stages, or shipping test databases.
docker history myapp:latest --human --no-trunc gives a quick tabular view. If one RUN line adds 800 MB, that is your target.
BuildKit and remote cache
Enable BuildKit (DOCKER_BUILDKIT=1) for parallel stage execution and cache mounts. In CI, export and import cache:
docker buildx build \
--cache-from type=registry,ref=ghcr.io/org/myapp:buildcache \
--cache-to type=registry,ref=ghcr.io/org/myapp:buildcache,mode=max \
-t ghcr.io/org/myapp:${SHA} .
mode=max caches intermediate layers, not just the final image — valuable for multi-stage builds where the compile stage is expensive.
Squash and compress — use sparingly
docker build --squash collapses layers into one, which can shrink size but destroys granular caching and is not supported in all environments. Prefer multi-stage builds that copy only artifacts to a minimal final stage over squashing a bloated single-stage image.
Use docker buildx build --compress or registry-side compression for network transfer; that does not change layer structure locally.
Layer order for cache hits
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Dependency files change rarely — put before source COPY. Use .dockerignore to exclude node_modules, .git, tests.
Common production mistakes
Teams get image layer optimization wrong in predictable ways:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
Production implementations of image layer optimization fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.
Debugging and triage workflow
When image layer optimization misbehaves in production, work top-down instead of guessing:
- Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
- Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
- Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
- Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
- Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
- Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.
Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.
Resources
- Dockerfile best practices (official docs)
- BuildKit cache backends
- dive — explore each layer in your Docker image
- Multi-stage builds
- BuildKit Dockerfile frontend syntax
Frequently asked questions
Why does changing one line in my Dockerfile rebuild everything?
Each Dockerfile instruction creates a layer. When an instruction's inputs change, that layer and all subsequent layers invalidate cache. If you COPY . . before RUN npm install, any source file change busts the dependency install layer. Copy lockfiles first, install dependencies, then copy source.
Should I combine all RUN commands into one layer?
Combine related commands in a single RUN to avoid redundant layers and reduce image size — especially cleanup in the same layer (apt-get install && rm -rf /var/lib/apt/lists/*). Do not merge unrelated steps; that hurts cache granularity. Balance cache hits against layer count.
How do I find which layers bloat my image?
Use dive (wagoodman/dive) to inspect layer-by-layer file additions, or docker history --no-trunc to see sizes per instruction. BuildKit's --progress=plain output shows cache miss reasons. Compare before/after when reordering COPY and RUN steps.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →