Dockerfile Best Practices¶
Each practice below exists to fix a specific, concrete failure mode — not as an arbitrary style rule. Where useful, a "before" and "after" Dockerfile shows exactly what changes and why.
Multi-stage builds: don't ship your build tools¶
The problem: compiling a Go binary, or building a frontend bundle, needs a compiler/toolchain that the final running application never needs. Shipping that toolchain in the production image bloats it by hundreds of megabytes and expands the attack surface for no runtime benefit.
Before (single stage, ships the whole Go toolchain):
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
This image is at minimum the size of the golang:1.22 base image
(~800MB+) plus the app — even though the running container only ever
needs the compiled server binary.
After (multi-stage — build in one stage, copy only the artifact into a minimal final stage):
# Stage 1: build
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
# Stage 2: run
FROM alpine:3.19
COPY --from=builder /app/server /server
CMD ["/server"]
The final image is built FROM alpine:3.19 (a few MB) and only contains
the compiled binary copied out of the builder stage with
COPY --from=builder. The entire Go toolchain, source code, and
intermediate build artifacts never exist in the final image at all —
Docker discards everything from the builder stage except what was
explicitly copied out. This is the standard pattern for any compiled
language, and for frontend apps (build with node, serve the static
output with nginx):
FROM node:20 AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM nginx:1.25-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
Pin versions — never use latest in anything meant to be reproducible¶
FROM node:latest means the exact same Dockerfile can build a
different image depending on the day it's run — latest is a moving
target, not a version. A build that worked yesterday can break today
with no code change, because the underlying base image changed
underneath it. Pin to a specific tag (node:20.11.1-slim), or better,
pin to a digest (node@sha256:abc123...) for byte-for-byte
reproducibility, since even a specific tag like 20.11.1 can technically
be re-pushed to point at different content (rare, but possible).
Order instructions from least-to-most frequently changing¶
Covered in depth in Docker Fundamentals — base image and dependency installation first, application source code last, so editing source code doesn't invalidate the (usually much slower) dependency-install layer.
Combine related RUN instructions to avoid layer bloat¶
Before:
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
Each RUN creates a separate layer. The apt-get update layer caches
the package index, but that index is now stale the moment it's not
immediately followed by the install in the same layer — and worse, the
rm -rf /var/lib/apt/lists/* cleanup, being a separate layer, doesn't
actually shrink the image: the files it deletes still exist in the
earlier layer and are part of the image's total size on disk (union
filesystems don't retroactively shrink a lower layer just because a
higher layer deletes a file from the merged view).
After:
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
One RUN, one layer — the cleanup happens within the same layer that
created the files, so they never get committed to a layer's disk
footprint in the first place. --no-install-recommends additionally
avoids pulling in optional packages the image doesn't need.
Run as a non-root user¶
The problem: by default, a process inside a container runs as root
— root inside the container's user namespace, which, without
additional isolation (user namespace remapping), maps to the same UID
0 the host kernel recognizes. A container-breakout vulnerability
combined with a root-running process gives an attacker root on the host,
not just inside an isolated sandbox.
FROM python:3.12-slim
RUN useradd --create-home appuser
WORKDIR /app
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "main.py"]
USER appuser switches the effective user for every instruction after
it, and for the running container itself. COPY --chown= ensures the
files the non-root user needs to read are actually owned by that user,
not left owned by root with no read permission for appuser.
Use a .dockerignore file¶
Without one, docker build . sends everything in the build context
directory to the Docker daemon, including .git/ (can be large),
node_modules/ (should be reinstalled fresh inside the image, not copied
from the host), and local .env files (a real risk: accidentally baking
secrets into an image layer, where they'd remain retrievable even if a
later layer "removes" them).
.git
node_modules
*.log
.env
__pycache__
Use a minimal base image where practical¶
alpine-based images (built on musl libc + busybox, not glibc) are
often 5–10× smaller than their Debian/Ubuntu-based equivalents, and a
smaller image means a smaller attack surface and faster pulls. The
trade-off: alpine's musl libc occasionally causes subtle behavioral
differences from glibc for compiled binaries that assume glibc (some
Python C extensions, in particular) — worth testing, not assuming, when
switching an existing Dockerfile to an alpine base.
Add a HEALTHCHECK¶
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
Without this, Docker (and orchestrators built on top of it) only know whether the container's main process is still running — not whether it's actually able to serve requests. A process that's alive but stuck (deadlocked, out of connections) looks identical to a healthy one without an explicit health check reporting otherwise.
Common pitfall¶
Copying the entire project with COPY . . before installing
dependencies (undoing the caching benefit described above) is the single
most common Dockerfile mistake — it's very easy to write, and it works
correctly, so nothing fails to point at the problem; it just makes
every build slower than it needs to be. If a build's "installing
dependencies" step reruns on every single code change, this is almost
always why.