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.
Order matters for cache efficiency
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.
Multi-stage builds to cut the fat
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.
Pin your base image, don't chase 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.
Run as a non-root user
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"]
Use .dockerignore
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
Common mistakes
- Running
apt-get update && apt-get installin a separateRUNfrom cleanup — the layer still contains the full apt cache even if yourm -rfit in a later layer. Combine install and cleanup in oneRUN. - Copying the whole repo before installing dependencies (breaks caching, covered above).
- Using
ADDwhenCOPYwould do —ADDhas surprising behavior with remote URLs and archive auto-extraction that's rarely what you actually want. - Leaving build secrets (npm tokens, API keys) as
ENVorARGvalues baked into layers, where anyone with image access can extract them withdocker history. Use--secretmounts (BuildKit) instead.
What I'd actually use
-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.
Next steps
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.