Introduction

The Dockerfile decides most of your image's size, build speed, and attack surface before a single container starts. The practices below are the ones we apply on every engagement — performance and security together, because in a Dockerfile they are the same discipline. Here is a reference multi-stage build for a Node 22 service that most of the sections below refer back to:

# syntax=docker/dockerfile:1

FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD ["node", "dist/healthcheck.js"]
CMD ["node", "dist/server.js"]

Build Practices

1. Start From a Minimal, Supported Base — and Pin It

Use slim, alpine, or distroless variants of official images, and never build on an end-of-life OS release (Ubuntu 20.04, for example, left standard support in April 2025). A tag like 22-bookworm-slim is mutable; for reproducible builds, pin by digest and let Renovate or Dependabot propose updates:

docker buildx imagetools inspect node:22-bookworm-slim
# then pin:  FROM node:22-bookworm-slim@sha256:<digest>
2. Use Multi-Stage Builds

Compilers, dev dependencies, and build caches belong in a build stage that gets thrown away. The runtime stage above ships only the built output and production dependencies — smaller image, faster pulls, and far fewer packages for a CVE scanner to flag. For compiled languages, the runtime stage can be a distroless image containing no shell at all.

3. Order Instructions for Layer Caching

Docker caches layers top-down and invalidates everything below the first change. Copy dependency manifests and install packages before copying source code — as in the example — so an ordinary code change reuses the cached npm ci layer instead of reinstalling everything.

4. Clean Up in the Same RUN Layer

Each RUN creates a layer; files deleted in a later instruction still ship in the earlier layer. Chain install and cleanup, and pin versions so the build is actually reproducible:

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl \
 && rm -rf /var/lib/apt/lists/*
5. Use .dockerignore, and Prefer COPY Over ADD

A .dockerignore excluding .git, node_modules, .env, and local build output keeps the build context small and keeps secrets and junk out of COPY . .. Use COPY unless you specifically need ADD's archive extraction — ADD's URL-fetching behavior is a classic source of surprise content in images.

Security Practices

6. Run as a Non-Root User

A container running as root is one kernel bug away from being root on the host. Official language images ship an unprivileged user (node above); otherwise create one with useradd and switch with USER before CMD. Install only what the application needs — every extra package is attack surface and scanner noise.

7. Never Bake Secrets Into Layers

Tokens passed via ARG, ENV, or a copied-then-deleted file remain recoverable from image history. BuildKit secret mounts expose credentials only for the instruction that needs them, leaving nothing in any layer:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

# at build time:
docker build --secret id=npmrc,src=$HOME/.npmrc -t shop-api .
8. Lint and Scan on Every Build

Lint Dockerfiles with hadolint, and scan built images for CVEs with Trivy or Grype in CI, failing the pipeline on critical findings:

hadolint Dockerfile
trivy image --severity HIGH,CRITICAL --exit-code 1 shop-api:1.8.3

One correction worth making explicit, because it circulates widely: Docker Bench for Security audits the Docker host and daemon configuration against the CIS benchmark — it does not scan Dockerfiles or images. Use it for host hardening; use Trivy, Grype, or Docker Scout for images. Scanning also belongs registry-side, since new CVEs land against images you built weeks ago — our guide on where to store Docker images covers registry scanning, signing, and retention.

9. Add a HEALTHCHECK

A HEALTHCHECK lets Docker, Compose, and orchestrators distinguish "process running" from "service actually answering" — which is what restart policies and depends_on conditions key on. Use exec form, as in the reference Dockerfile, so it works in shell-less images.

Conclusion

None of these practices is exotic; the difficulty is applying them consistently across dozens of repositories. That's a pipeline and policy problem as much as a Dockerfile problem — the kind of baseline our SecOps team builds into CI so it stays enforced, and our DevOps practice keeps fast. Start with the reference build above, add the scanner gate, and iterate from there.