Dockerfile Best Practices

A Dockerfile is a build script for an image. A good one optimises four things at once: build speed, image size, reproducibility and security. Most of the techniques serve several of these together.

A reference Dockerfile

# syntax=docker/dockerfile:1
 
# ---- build stage -----------------------------------------------------------
FROM --platform=$BUILDPLATFORM golang:1.24-bookworm AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
 
# Dependencies first: this layer is reused until go.mod/go.sum change
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
 
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
 
# ---- runtime stage ---------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]

Everything below explains a line of it.

Multi-stage builds

Compilers, package managers and source code are needed to build an application and are a liability when running it. A multi-stage build uses one stage with the full toolchain and copies only the result into a minimal final stage. Benefits: an image a fraction of the size, far fewer packages for scanners to flag, and no build-time credentials or source in what ships.

Interpreted languages use the same idea — install dependencies in one stage, copy the virtual environment or node_modules into a slim runtime stage.

Order for the cache

The builder reuses a layer only if nothing before it changed. Put slow, stable steps first and fast-changing ones last:

  1. Base image and system packages
  2. Dependency manifests, then dependency installation
  3. Application source
  4. Build

Cache mounts keep package-manager caches between builds without storing them in the image:

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && apt-get install -y --no-install-recommends ca-certificates

In CI, where runners start empty, export the cache to a registry: --cache-to type=registry,ref=…/app:buildcache,mode=max --cache-from type=registry,ref=…/app:buildcache.

A .dockerignore is not optional

Everything in the build context is sent to the builder, and COPY . . copies it into the image.

.git
node_modules
dist
*.env
.aws
**/*.pem
Dockerfile*

Without it, builds are slow, the cache is invalidated by irrelevant changes, and local secrets end up in layers.

Choose and pin the base image

  • Use a minimal base: distroless, Chainguard, -slim or Alpine. Fewer packages means fewer vulnerabilities and less to patch.
  • Pin a specific version, never latest. For full reproducibility pin the digest: FROM debian:12.9-slim@sha256:…. Let Renovate or Dependabot propose updates so pinning does not mean stale.
  • Prefer official or verified-publisher images, and rebuild regularly to pick up base-image patches.

A distroless image has no shell, which also means an attacker has none. Debug with an ephemeral container instead — kubectl debug.

Never put secrets in a layer

ARG and ENV values are recorded in the image history, and a file deleted in a later layer is still present in the earlier one. Use build secrets, which are mounted only for the duration of one instruction:

RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
docker build --secret id=npm_token,env=NPM_TOKEN .

For private Git or SSH dependencies use --mount=type=ssh. Runtime secrets do not belong in the image at all; inject them when the container starts — Secrets.

Run as a non-root user

By default the process runs as root. Combined with a container escape or a mounted host path, that is root on the node.

RUN groupadd --system --gid 10001 app && useradd --system --uid 10001 --gid app app
COPY --chown=app:app . /app
USER 10001:10001

Use a numeric UID so Kubernetes can verify runAsNonRoot without resolving a name. Listen on a port above 1024. Make the application write only to explicit directories so the root filesystem can be mounted read-only — see security context and container security.

PID 1, signals and shutdown

Your process becomes PID 1 in its PID namespace, and PID 1 is special: signals without an installed handler are ignored, and it must reap orphaned children.

ENTRYPOINT ["/app"]                # exec form: your binary is PID 1 and receives SIGTERM
ENTRYPOINT /app                    # shell form: /bin/sh is PID 1 and does NOT forward SIGTERM
  • Always use the exec form (JSON array). With the shell form, docker stop and Kubernetes pod termination wait out the grace period and then SIGKILL — every deploy drops in-flight requests.
  • In a wrapper script, finish with exec "$@" so the application replaces the shell.
  • If the application spawns children and does not reap them, add a minimal init such as tini.
  • Handle SIGTERM: stop accepting work, finish what is in flight, exit. Background in signals and pod lifecycle.

ENTRYPOINT sets the executable; CMD supplies default arguments that docker run image args or a pod’s args replace.

Smaller points that add up

DoWhy
COPY, not ADDADD silently extracts archives and fetches URLs
Combine apt-get update and install in one RUN; clean in the same RUNAvoids stale cached package lists and leftover files in a layer
--no-install-recommends, npm ci --omit=dev, pip install --no-cache-dirLeaves out what production does not need
WORKDIR instead of RUN cdEach RUN starts in a fresh shell
Log to stdout and stderrThe platform collects it — logging
One concern per containerScale, restart and upgrade independently
OCI labels: org.opencontainers.image.source, .revision, .versionTrace any running image back to its commit
HEALTHCHECK for plain Docker; probes for KubernetesKubernetes ignores HEALTHCHECK — use probes

Verify

hadolint Dockerfile                                # lint
docker build --check .                             # BuildKit's built-in checks
docker buildx build --sbom=true --provenance=mode=max -t app:1.4.2 .   # attach SBOM and provenance
trivy image app:1.4.2                              # vulnerabilities and misconfigurations
dive app:1.4.2                                     # wasted space per layer
docker run --rm --read-only --cap-drop=ALL --user 10001 app:1.4.2      # does it run locked down?

Wire the linter and scanner into CI and fail on critical findings — container image scanning.

Alternatives to writing a Dockerfile

  • Cloud Native Buildpacks detect the language and produce an image with no Dockerfile.
  • ko (Go), Jib (Java) build images directly from source without a Docker daemon.
  • Nix and Bazel give bit-for-bit reproducible images at the cost of a steeper learning curve.
  • Kaniko and Buildah build Dockerfiles without a privileged daemon, which suits CI inside Kubernetes.

Review checklist

  • Multi-stage, minimal final image
  • Base image pinned to a version or digest
  • .dockerignore present
  • Dependencies copied and installed before source
  • No secrets in ARG, ENV or files
  • Non-root numeric USER
  • Exec-form ENTRYPOINT; SIGTERM handled
  • Scanned, with an SBOM attached

Across the wiki