Dockerfile Best Practices
A Dockerfile is a build recipe, and small choices in it compound. Instruction order decides whether a one-line code change rebuilds in two seconds or four minutes. The base image decides whether you ship 30 MB or 1.2 GB, and whether a scanner reports 3 CVEs or 300. A missing USER line decides whether a compromised app runs as root.
This page covers the habits that make Docker images fast to build, small to ship, and safe to run. Multi-stage builds get their own page because they're the single biggest lever for image size.
TL;DR
- Order instructions from least to most frequently changing. Copy dependency manifests and install dependencies before copying source code.
- Use a
.dockerignoreto keep.git,node_modules, secrets, and build output out of the build context. - Pick a small, maintained base image (slim, Alpine, distroless, Chainguard/Wolfi) and pin it by tag or digest.
- Combine related
RUNsteps and clean package manager caches in the same layer. - Run as a non-root user, and never bake secrets into layers; use BuildKit secret mounts.
- Use exec-form
CMD/ENTRYPOINTso your process receives signals.
Quick Example
A cache-friendly, non-root Node.js image:
Editing server.js now rebuilds only the final COPY layer. Adding a dependency re-runs npm ci, using BuildKit's cache mount so it doesn't re-download everything.
Core Concepts
Layers and the Build Cache
Each instruction that changes the filesystem (RUN, COPY, ADD) creates a layer. When rebuilding, Docker reuses a cached layer if the instruction and its inputs are unchanged. Once one layer's cache is invalidated, every layer after it rebuilds. That's why ordering matters so much: put slow, rarely-changing steps (OS packages, dependency installs) early, and fast, frequently-changing steps (copying source) late.
For COPY and ADD, the cache key includes a checksum of the copied files. That's why COPY . . before npm install busts the install cache on every code change.
The Build Context
docker build . sends the directory (the context) to the builder. Without a .dockerignore, that includes .git, local node_modules, and maybe .env files. Builds get slower, caches get invalidated by irrelevant changes, and secrets can end up in the image.
Base Images
Smaller images pull faster, start faster, and expose less attack surface. See container security.
BuildKit Features
Modern Docker builds with BuildKit, which adds:
RUN --mount=type=cachefor persistent package caches (npm, pip, apt, Go modules) across builds.RUN --mount=type=secretto use a credential during a build step without storing it in any layer.- Parallel execution of independent stages.
docker buildxfor multi-platform images (linux/amd64,linux/arm64).
Best Practices
Combine and Clean Up in One RUN
Files deleted in a later layer still exist in the earlier one, so the image doesn't shrink. Install and clean up in the same instruction:
Pin Versions
Use specific tags (python:3.13-slim) rather than latest. For strict reproducibility, pin the digest (python:3.13-slim@sha256:…) and let Renovate or Dependabot bump it. Pin application dependencies with lockfiles and install with npm ci, pip install --require-hashes, or go mod download.
Run as Non-Root
Create or use an unprivileged user and switch to it with USER. Many official images ship one (node, nobody); distroless images offer :nonroot tags. Combined with a read-only root filesystem at runtime, this blunts most container breakout attempts.
Use Exec Form for CMD and ENTRYPOINT
Without signal delivery, docker stop and Kubernetes shutdowns wait out the grace period and then SIGKILL your app mid-request. If your app can't handle PID 1 duties such as reaping zombies, add --init or tini.
Prefer COPY Over ADD
ADD also fetches URLs and auto-extracts archives, which is surprising behavior. Use COPY unless you specifically need extraction.
Add Metadata and a Health Check
LABEL org.opencontainers.image.source=… links images to source. HEALTHCHECK is useful for plain Docker and Compose; Kubernetes ignores it in favor of probes.
Common Mistakes
Copying Everything Before Installing Dependencies
Baking Secrets Into the Image
docker history and image layer tarballs expose build args and every file ever written. Treat anything that touched a layer as published.
Running apt-get update in Its Own Layer
A cached apt-get update layer combined with a later apt-get install can install stale or missing packages. Always run them together in one RUN.
FAQ
Should I use Alpine?
Alpine is small, but it uses musl instead of glibc. That can break prebuilt native binaries, slow down Python wheels (which often must compile from source), and change DNS resolution behavior. For most apps, a -slim Debian image or a distroless image is a safer small default. Alpine is fine when you've tested it with your stack.
How do I make builds faster in CI?
Order layers for caching, use BuildKit cache mounts, and persist the cache between CI runs with --cache-to/--cache-from (registry or GitHub Actions cache backends). Keep the context small with .dockerignore. See GitHub Actions caching.
What's the difference between CMD and ENTRYPOINT?
ENTRYPOINT defines the executable; CMD provides default arguments, which are replaced by anything passed to docker run. A common pattern is ENTRYPOINT ["myapp"] with CMD ["--help"]. If you only need one, CMD alone is simplest.
How do I scan my image for vulnerabilities?
Use docker scout cves, Trivy, or Grype locally and in CI, and fail builds on critical findings that have fixes available. Rebuilding regularly on patched base images removes most findings without code changes.
Related Topics
- Docker — The container platform overview
- Multi-Stage Builds — Separating build tools from the runtime image
- Docker Compose — Running multi-container apps locally
- Container Security — Hardening images and runtime
- CI/CD — Building images in pipelines