Dockerfiles Best Practices
This is a working checklist for production Dockerfiles - fast to build, small to ship, and safe to run. Apply the groups in order; each practice states what to do and why it matters.
How to Use This List
Treat this as a review gate for any Dockerfile heading to production. Skim the group headers, then check each bold practice against your file.
Not every item fits every app, but you should be able to justify each one you skip. Pair it with a linter such as hadolint in CI so regressions are caught automatically.
A - Base Images
- Pin to a specific tag, not
latest.latestmoves under you and makes builds non-reproducible; usenode:22-slimovernode:latest. - Pin base digests for reproducibility.
FROM node:22-slim@sha256:...guarantees the exact bytes even if the tag is re-pushed. - Prefer minimal bases. Slim, Alpine, or distroless cut size and CVE surface; match the variant to your libc and debugging needs.
- Track base updates deliberately. Rebuild on a schedule so security patches in the base flow into your image instead of drifting stale.
B - Layer Ordering & Cache
- Copy dependency manifests before source.
COPY package.jsonthen install, thenCOPY . ., so code edits do not bust the dependency cache. - Put stable instructions early, volatile ones late. Base setup and OS packages first; application source last.
- Use deterministic installs.
npm ci,pip install -rwith a lockfile, or pinnedaptversions keep builds repeatable. - Add a
.dockerignore. Excluding.git,node_modules, and secrets shrinks the context and protects the cache and the image.
C - Image Size & Layers
- Chain related
RUNcommands. Combineapt-get update && install && rm -rf /var/lib/apt/lists/*in one layer so caches never persist. - Clean up in the same layer that creates the mess. A file removed in a later layer still ships via a whiteout; remove it where it was added.
- Use multi-stage builds. Compile in a builder and
COPY --fromonly artifacts, so toolchains never reach production. - Use BuildKit cache mounts for package caches.
RUN --mount=type=cachespeeds installs without baking the cache into a layer.
D - Security
- Run as a non-root user. Add a numeric
USERso a compromised process is unprivileged; it also satisfiesrunAsNonRoot. - Never bake secrets into layers. Use
--mount=type=secret, notARGorENV, since args and env persist in image history. - Drop to a read-only-friendly layout. Own app files with
COPY --chownand write only to mounted volumes soreadOnlyRootFilesystemworks. - Scan and sign images. Run Trivy or Grype in CI and sign with cosign so only vetted, verified images deploy.
E - Runtime Correctness
- Use the exec form for
CMD/ENTRYPOINT.["node", "server.js"]makes the process PID 1 and delivers signals for clean shutdown. - Set an explicit
WORKDIR. It avoids surprises from relative paths and never relies on a non-persistentRUN cd. - Document ports with
EXPOSE. It signals intent to readers and tooling, even though publishing happens at run time. - Handle signals for graceful shutdown. Ensure the app catches
SIGTERM; add an init liketinionly if the process cannot reap children.
F - Build Portability & CI
- Pin the Dockerfile frontend. Start with
# syntax=docker/dockerfile:1to unlock BuildKit mounts and stable parsing. - Build multi-arch where needed. Publish
linux/amd64andlinux/arm64for Apple Silicon and Graviton viadocker buildx. - Lint Dockerfiles in CI.
hadolintcatches unpinned versions, root users, and shell-form pitfalls before review. - Keep builds hermetic. Avoid network calls that are not pinned; unversioned
apt-get/curlmakes builds drift over time.
When You Are Done
Rebuild from a clean cache and confirm the image is small, non-root, and free of high-severity CVEs. Run docker history and a scanner as the final check.
The finished Dockerfile should build fast on a warm cache, produce a minimal runtime image, and pass your linter and image policy without manual exceptions.
Record the final image digest in your deployment manifests rather than a floating tag, so what you tested is exactly what runs. Promote that digest through environments instead of rebuilding per stage.
Finally, confirm the runtime posture end to end: the container starts as a non-root UID, writes only to mounted volumes, and stops cleanly on SIGTERM. A Dockerfile that builds well but cannot shut down gracefully still causes rollout pain.
FAQs
Should I always pin base image digests? For production and reproducible builds, yes. Digests guarantee identical bytes; combine with scheduled rebuilds to still get security updates.
Why chain RUN commands? Each RUN is a layer. Chaining lets you install and clean up in one layer so caches and temp files never ship.
Is Alpine always the best minimal base? No. Alpine uses musl, which breaks some glibc binaries and native modules. Distroless or slim can be safer for those.
How do I keep secrets out of the image? Use BuildKit secret mounts. Never pass tokens via ARG or ENV, since both persist in image history.
Do I need multi-arch images? Only if your image runs on more than one CPU architecture, such as Graviton nodes plus x86, or developers on Apple Silicon.
What runs the container in Kubernetes? containerd via the CRI runs your pods on nodes. Docker builds the image; it is not the in-cluster runtime.
Should I set requests, limits, and probes in the Dockerfile? No. Those belong to the Kubernetes workload spec, not the image. The Dockerfile's job is a correct, minimal, non-root image; the pod spec sets resources and health checks.
Do I need a linter if I follow this list by hand? A linter catches regressions the moment they are introduced. Manual review drifts over time, so run hadolint in CI as a backstop even with a disciplined team.
Related
- How a Dockerfile Becomes an Image
- Multi-Stage Builds
- BuildKit & Cache Mounts
- Non-Root Users in Images
- Distroless & Minimal Bases
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).