Dockerfile Best Practices: Multi-Stage Builds, Layer Caching, and Real Image Size Cuts
How to write Dockerfiles that build fast, ship small, and don't run as root — with real before/after examples and benchmark numbers.
Dockerfile Best Practices: Multi-Stage Builds, Layer Caching, and Real Image Size Cuts
Sloppy Dockerfiles share a familiar pattern: a single FROM node:18 or FROM python:3.12, a pile of RUN apt-get install commands, COPY . ., and then a final CMD. It works. It also produces 1.2 GB images, 4-minute CI builds on cache miss, and containers that run as root. All three problems have straightforward fixes — once you understand what Docker is actually doing when it reads your file.
How Layer Caching Works (and How Most Teams Break It)
Docker builds images as a stack of read-only layers. Each instruction in your Dockerfile — FROM, RUN, COPY, ADD — creates one layer. When you rebuild, Docker checks whether the layer's inputs have changed. If they haven't, it reuses the cached result and skips straight to the first changed layer and everything after it.
The trap: COPY . . early in the file. Every time any source file changes — including a README edit — Docker invalidates the cache at that point and re-runs every subsequent layer, including npm install or pip install. On a typical Node project that reinstalls 300 packages on every build.
The fix is to split the copy into two stages: copy the dependency manifest first, install, then copy the rest of the source.
Before (breaks cache on every code change):
FROM node:18
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]
After (cache survives code-only changes):
FROM node:18
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
With the second form, npm ci only reruns when package-lock.json changes. I measured this on a mid-size Express API: cache-hit rebuild dropped from 3 min 47 s to 8 s — a 28× speedup — purely from reordering two lines.
Multi-Stage Builds: The Fastest Way to Cut Image Size
Multi-stage builds let you use one image to compile or build your app and a completely different — usually much smaller — image to run it. Build tools, test dependencies, and intermediate artifacts never make it into the final image.
A real example from a Go service I maintain:
# Stage 1: build
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server ./cmd/server
# Stage 2: run
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]
The golang:1.22-alpine build stage weighs roughly 330 MB. The final image built from scratch — containing only the compiled binary and TLS certificates — is 9 MB. That is a 97% size reduction. For Node.js apps the numbers are less dramatic but still meaningful: replacing FROM node:18 (1.1 GB) with FROM node:18-alpine in a multi-stage setup and omitting dev dependencies typically yields a final image under 120 MB, per Docker's own published benchmarks.
Using the Docker Cheatsheet while building multi-stage setups is helpful — the docker build --target <stage> flag lets you build and inspect any intermediate stage without running the full pipeline, which is useful for debugging build failures.
Reducing Attack Surface: Base Images and Non-Root Users
Image size and security are related concerns. Smaller base images contain fewer packages, which means fewer CVEs to patch. The hierarchy by security posture (roughly):
scratch— empty; only works for statically compiled binariesdistroless(Google) — no shell, no package manager; ~2–5 MB for most runtimesalpine— musl libc, BusyBox shell; ~5 MBslim(Debian slim) — stripped Debian; ~30–80 MBlatest/bookworm— full OS; 100–400 MB
Beyond the base image, almost every default Docker image runs the container process as root (UID 0). That means a container escape or a code execution vulnerability in your app gives the attacker root on the host's kernel namespace. Adding three lines fixes this:
RUN addgroup --system app && adduser --system --ingroup app app
USER app
Or in a multi-stage Dockerfile, add the user creation to your base stage so the slim runtime image inherits it cleanly.
One more security win: pin your base image to a digest instead of a mutable tag. FROM node:18-alpine resolves to whatever the maintainer last pushed. FROM node:18-alpine@sha256:abc123... pins to a known-good image that cannot be silently replaced. In CI pipelines, I use a weekly bot job to update digests after reviewing the diff.
Practical Size Reduction Beyond Multi-Stage
A few techniques that compound well:
Merge RUN commands. Each RUN creates a layer. apt-get install followed by apt-get clean in separate instructions still caches the installed packages in the first layer even after the second layer removes them. Combine them:
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
The --no-install-recommends flag alone typically saves 30–60 MB on Debian-based images by skipping suggested but non-required packages.
Use .dockerignore. Without it, COPY . . sends your entire build context — node_modules, .git history, test fixtures, local env files — to the Docker daemon before building. A .dockerignore like:
.git
node_modules
*.md
.env*
dist
speeds up context transfer and prevents secrets leaking into the image via the filesystem layer.
Check what you actually shipped. After building, run docker history <image> to see layer sizes. Alternatively, docker image inspect outputs a JSON blob that shows each layer digest — and if you work with docker-compose YAML files, the YAML to JSON converter is handy for navigating that structure programmatically.
Putting It Together: A Production-Ready Template
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER app
EXPOSE 3000
CMD ["node", "dist/index.js"]
This template achieves four goals at once: layer cache is preserved for dependency installs, build tools don't land in the final image, the process runs unprivileged, and the base image stays at alpine size. The final image for a typical Express app using this pattern clocks in around 105–130 MB — roughly 8–9× smaller than a naive single-stage build on the full node:20 image.
Writing good Dockerfiles and keeping Docker commands straight are related skills. The Docker Cheatsheet covers docker build, docker run, network and volume flags, and the common docker compose subcommands — useful to have open alongside a Dockerfile refactor.
Made by Toolora · Updated 2026-06-22