Server--:--:--You--:--:--
SYSADMIN - NET TOOLSby Prabath Thalangama

Dockerfile Best Practices That Actually Matter

By Prabath Thalangama· August 27, 2026· 3 min read
#docker#containers#ci-cd

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 denied after adding USER — the process needs to write somewhere it doesn't own. Create and chown the directory in a RUN before the USER line, or mount a volume.
  • Scanner flags dozens of CVEs — you're on a full debian/ubuntu base. Move to -slim, alpine, or distroless; most of the findings are in packages you don't use.
  • COPY fails in CI but works locally — the file is .dockerignored or .gitignored and your CI checks out a clean tree.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.