A practical guide to dockerfile best practices for smaller, secure production images.
Your CI pipeline takes 6 minutes to push a Docker image because it's 1.8GB, and half of that is apt package caches and dev headers nobody needs at runtime. Then a security scan flags 40 CVEs, most of them in packages your app never touches. Both problems trace back to the same root cause: treating the Dockerfile like a shell script instead of a build spec.
Docker caches each layer and invalidates everything after the first changed layer. Copying your entire source tree before installing dependencies means every code change reinstalls every dependency:
# Bad: any source change invalidates the dependency install layer
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]
# Good: dependencies only reinstall when package files change
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
That reordering alone can cut a typical rebuild from minutes to seconds when only application code changed.
Build tools, dev dependencies, and compilers don't belong in your final image:
FROM node:20 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
CMD ["node", "dist/server.js"]
The builder stage has the full toolchain; the final stage only has what's needed to run. This routinely takes images from 1GB+ down to 150-300MB for a typical Node service.
latest# Bad — breaks reproducibility, "latest" changes under you
FROM node:latest
# Good — pin to a specific digest for full reproducibility
FROM node:20.11.1-slim@sha256:9f6b...
Pinning by digest (not just tag) means the exact same bytes get pulled every time, everywhere — no surprise upgrades breaking prod on a Friday because latest moved.
Most official images run as root by default, which is a real risk if a container is ever compromised — root inside the container often maps to more privilege than you'd want:
FROM node:20-slim
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["node", "server.js"]
Without one, COPY . . drags .git, node_modules, local .env files, and test fixtures into your build context, slowing builds and occasionally leaking secrets into image layers:
node_modules
.git
.env*
*.md
dist
coverage
apt-get update && apt-get install in a separate RUN from cleanup — the layer still contains the full apt cache even if you rm -rf it in a later layer. Combine install and cleanup in one RUN.ADD when COPY would do — ADD has surprising behavior with remote URLs and archive auto-extraction that's rarely what you actually want.ENV or ARG values baked into layers, where anyone with image access can extract them with docker history. Use --secret mounts (BuildKit) instead.-slim or -alpine variants of official images as your base, multi-stage builds by default (even for simple apps — the discipline pays off), and BuildKit's --mount=type=cache for package manager caches so repeated builds stay fast without bloating the final image. Skip Alpine specifically for anything using native Node addons or glibc-dependent binaries — the musl libc mismatch causes just enough weird bugs that -slim (Debian-based) is often the better trade for those cases.
Run docker history <your-image> on your current production image and look at layer sizes — the biggest layer is usually your best optimization target. Then add a multi-stage build if you don't already have one; it's the single highest-leverage change most Dockerfiles are missing.