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

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:

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

References