Dockerfile Best Practices That Actually Matter
Introduction¶
Most Dockerfiles work. Far fewer are small, fast to rebuild, and clean in a scanner. The difference is a short list of habits. This post covers the ones with the biggest payoff, with before/after examples you can apply today.
Concepts¶
An image is a stack of read-only layers. Each instruction in a Dockerfile
that changes the filesystem (RUN, COPY, ADD) creates one. Two things
follow from that:
- Cache invalidation is positional. When a layer changes, every layer after it is rebuilt. Order instructions from least- to most-frequently changing.
- Deleting a file in a later layer doesn't shrink the image — the file is
still in the earlier layer. Clean up within the same
RUN.
Hands-on¶
Order for cache hits¶
Copy dependency manifests and install before copying your source, so a code change doesn't bust the dependency layer:
# Bad: any source change reinstalls everything
COPY . .
RUN pip install -r requirements.txt
# Good: deps layer is cached until requirements.txt changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
Multi-stage builds¶
Build in a fat image, ship only the artifact:
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
The final image has no compiler, no shell, no package manager — often 10–20 MB instead of 800 MB, and a much smaller attack surface.
Clean up in the same layer¶
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
Splitting the rm into its own RUN would leave the apt cache in the previous
layer.
Run as non-root¶
RUN useradd --system --uid 10001 app
USER 10001
Or use a base image that already does it (nginxinc/nginx-unprivileged,
distroless nonroot). A container that runs as root is one container-escape
away from host root.
.dockerignore¶
.git
node_modules
**/__pycache__
*.log
.env
This keeps COPY . . from shipping secrets, bloating the context, and busting
the cache on irrelevant changes. The build context is uploaded to the daemon in
full — a stray node_modules can add hundreds of megabytes and seconds.
Pin things¶
FROM python:3.12.7-slim-bookworm
Not python:3.12, and definitely not python:latest. A moving tag makes
builds non-reproducible and lets an upstream change break you silently. For
supply-chain-sensitive builds, pin by digest (python@sha256:...).
Verification and troubleshooting¶
# See the layers and their sizes.
docker history --no-trunc your-image:tag
# Build with BuildKit and inspect cache usage.
DOCKER_BUILDKIT=1 docker build --progress=plain -t your-image:tag .
# What's actually in the final image.
docker run --rm your-image:tag sh -c 'du -sh /* 2>/dev/null | sort -h'
Common problems:
- Every build reinstalls dependencies — you
COPY . .before the install step, or a file the manifest doesn't need (a log, a build timestamp) is in the context and changing. - Image is huge despite a cleanup
RUN— the cleanup is a separate instruction from the install. Combine them with&&. permission deniedafter addingUSER— the process needs to write somewhere it doesn't own. Create andchownthe directory in aRUNbefore theUSERline, or mount a volume.- Scanner flags dozens of CVEs — you're on a full
debian/ubuntubase. Move to-slim,alpine, or distroless; most of the findings are in packages you don't use. COPYfails in CI but works locally — the file is.dockerignored or.gitignored and your CI checks out a clean tree.
Related tools and reading¶
- On-site: Kubernetes Manifest Generator, .htpasswd Generator.
- Related posts: Multi-stage builds, explained, Shrink a Docker image.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.