Dockerfile Rules
These are the enforceable rules for writing production Dockerfiles - the ones you can lint with hadolint, verify in CI, and gate a merge on.
The through-line is simple: small, reproducible, non-root images with no secrets baked in. Every rule below maps to a real failure mode or CVE class.
How to use this list
Wire the cheap checks into hadolint and a CI scan with Trivy or Grype. Review pull requests against the grouped items so no rule is silently skipped.
Each item is phrased as a gate. If a Dockerfile cannot pass it, it should not merge.
Base image and reproducibility
- Pin the base by digest -
FROM node:22.14-slim@sha256:...; a floating tag can change under you and break reproducibility. - Prefer minimal or distroless bases -
-slim,alpinewhere glibc is not required, orgcr.io/distroless/*to shrink the attack surface. - Rebuild regularly to absorb patches - a digest-pinned base still needs a scheduled bump to pick up CVE fixes.
- Use a single, known registry - avoid mixing unvetted public bases; mirror through a trusted internal registry.
Multi-stage builds and image size
- Use multi-stage builds - compile in a build stage, copy only artifacts into a slim runtime stage.
# build stage - toolchain lives here and never ships
FROM golang:1.24-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
# runtime stage - minimal, no compiler
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]- Copy only what you need -
COPY --from=build /out/app /app, not the whole build tree. - Combine related
RUNsteps and clean up - remove apt caches in the same layer that created them. - Add a
.dockerignore- keep.git,node_modules, secrets, and test fixtures out of the build context.
Non-root and runtime hardening
- Run as a non-root user - declare a numeric
USERso KubernetesrunAsNonRootcan verify it.
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin appuser
USER 10001- Use a numeric UID -
USER 10001lets the kubelet enforce non-root without resolving a username. - Do not install a shell or package manager into runtime - distroless images remove this entire class of exploit.
- Set a fixed
WORKDIR- avoid writing to/and make relative paths predictable.
Secrets, caching, and build integrity
- Never
COPYorENVa secret - layers are cached and distributable; a leaked layer leaks the secret forever. - Use BuildKit secret mounts -
RUN --mount=type=secretexposes a credential only for that step, never in a layer.
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev- Use cache mounts for dependencies -
--mount=type=cachespeeds builds without polluting the image. - Order layers dependency-first - copy lockfiles and install before copying source so code changes do not bust the dependency cache.
- Pin dependency versions - a lockfile plus digest-pinned base is what makes a build reproducible.
Metadata and CI enforcement
- Prefer
ENTRYPOINTin exec form -ENTRYPOINT ["/app"]avoids a shell wrapper and forwards signals correctly for clean shutdown. - Add OCI labels -
org.opencontainers.image.sourceandrevisionfor provenance and traceability. - Lint every Dockerfile - run
hadolintin CI and fail on its findings. - Scan and sign the built image - Trivy/Grype for CVEs, cosign for signatures, both blocking the pipeline.
A note on the runtime boundary: Docker with BuildKit is your build and local-dev tool. It does not run your pods in the cluster - containerd executes them via the CRI on nodes, so these Dockerfile rules are about producing a good artifact, not about cluster runtime.
FAQs
Why pin by digest when I already use a version tag?
A version tag like 22.14-slim is still mutable; the maintainer can repush it. The digest is content-addressed and cannot change.
Distroless has no shell - how do I debug?
Use an ephemeral debug container (kubectl debug) or a :debug distroless variant locally, keeping the production image shell-free.
Does a non-root USER in the Dockerfile satisfy Kubernetes?
It is necessary but pair it with securityContext.runAsNonRoot: true so the platform enforces it regardless of the image.
How do I pass build-time credentials for a private registry?
Use RUN --mount=type=secret with BuildKit, never ARG or ENV, because build args are visible in image history.
Alpine or distroless? Distroless for compiled languages and the smallest attack surface; slim or Alpine when you need a shell or glibc, accepting the larger surface.
Where should I bump base images? On a schedule and on CVE alerts, via an automated dependency bot, so digest pinning does not mean shipping stale bases.
Related
- Containers Rules Checklist
- Kubernetes Manifest Rules
- Why Rules Beat Tribal Knowledge
- Containers Rules Best Practices
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).