docker
Dockerfile best practices in 2026 — the patterns that actually matter
Most Dockerfile guides are stale. Here are the patterns that pay off in production: multi-stage builds, build cache mounts, distroless bases, and the rootless story.
May 19, 2026 · 8 min · by Sudhanshu K.
Dockerfile best practices in 2026 — the patterns that actually matter
Most "Dockerfile best practices" articles still in Google's top 10 were written between 2019 and 2022. They're not wrong, exactly — they're just missing the patterns that have actually become best practice since then.
This is the Dockerfile checklist we use when we onboard a customer onto managed Docker hosting. Not "what you'll find in a Docker tutorial," but what you'll find in production-quality Dockerfiles in 2026.
Use BuildKit. Use it explicitly.
BuildKit has been the default since Docker 23.0, but in CI environments it's not always enabled. Force it explicitly:
# syntax=docker/dockerfile:1.7That one comment at the top of the Dockerfile pins the frontend version and enables BuildKit features that aren't available in the legacy builder — most importantly, the cache mounts and secrets mounts below.
Multi-stage builds: the new normal
The pattern:
# syntax=docker/dockerfile:1.7
# ---- builder stage ----
FROM golang:1.22-alpine AS builder
WORKDIR /src
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 go build -trimpath -ldflags='-s -w' -o /out/app ./cmd/app
# ---- runtime stage ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]What's happening here that wasn't in a 2020 Dockerfile:
--mount=type=cachepersists dependency caches between builds. For Go, this savesgo mod downloadand the build cache. For Node, it savesnode_modules. For Python, it savespipcache. For Rust, it savestarget/. This single feature can cut CI build times from 8 minutes to 90 seconds.- Distroless final image. No shell, no package manager, no curl. ~2MB instead of ~50MB for Alpine. The attack surface is dramatically smaller — most webshells and post-exploitation tools assume a shell exists. They don't get one.
USER nonroot:nonrootbaked in at the image level. NorunAsUserneeded in pod spec — the image is non-root by default, and you have to opt into running as root.
Order layers by churn
This is the oldest rule and still the most-violated. Frequently-changing files (your source code) belong at the bottom of the Dockerfile. Rarely-changing files (dependency manifests) belong at the top.
For a Node app:
# Top: rarely changes, cached most often
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Bottom: changes every commit
COPY src ./src
RUN npm run buildGet this backwards and you re-run npm ci on every code change — which is 90% of your build time. Get it right and npm ci runs once a week.
Secrets: never bake them in
In 2026 there is no excuse for ARG DB_PASSWORD in a Dockerfile. The secret will be in the image history, the registry, and anywhere an attacker has eyeballs.
The correct pattern:
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ciThen on the build host:
docker buildx build --secret id=npm_token,src=$HOME/.npmrc-token .The secret is mounted into the build for the duration of the RUN command and then removed. It never appears in any image layer. This is the right way to fetch private npm packages, private composer packages, anything that needs an auth token at build time.
Image size is a security property, not just a cost property
Smaller images:
- Pull faster (lower deploy latency)
- Have a smaller attack surface (fewer binaries means fewer CVE matches)
- Are easier to audit
Tooling to measure:
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
dive my-image:latest # interactive layer explorer
docker scout cves my-image:latestIf your application image is over 100MB and you're not running a JVM workload, something is wrong. The most common culprits:
- Building in the runtime image. Multi-stage builds (above) fix this.
- Carrying build toolchains into runtime. Don't
apt-get install build-essentialin the runtime stage. - Carrying test data into runtime. Use
.dockerignoreaggressively. - Default base image too large.
python:3.12is 900MB;python:3.12-slimis 120MB; distroless Python is 60MB.
Pin everything, but pin to digests for base images
Tags like python:3.12-slim move. The image you build today is not the image you build next month. For reproducibility:
FROM python:3.12-slim@sha256:f11725aba18c19664a408902103365eaf8013823ffc56270f921d1dc78a198cbYes, it's ugly. Yes, it's correct. Renovate/Dependabot will rewrite the digest for you on a schedule, opening a PR per base image update. You get reproducible builds and automated freshness, which is what you want.
For internal images that aren't subject to "what's the upstream Python doing this week" — same rule. Pin to digest, automate the update.
Healthchecks: include them, make them cheap
The HEALTHCHECK instruction matters more than it gets credit for. Orchestrators (Docker Swarm, ECS, even Kubernetes when wired through CRI) use it for readiness.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost:8080/healthz || exit 1The check should be cheap — not a full DB query, not a synthetic transaction. A liveness check is "is the process answering at all." Readiness is a separate concept; if you need real readiness, use the orchestrator's separate readiness probe.
.dockerignore is not optional
The single highest-ROI file in any Dockerfile project. A minimum:
.git
.github
.vscode
node_modules
dist
target
*.log
.env*
README.md
docs/
test/
__pycache__
*.pyc
.DS_Store
This prevents:
- Leaking secrets from
.env - Leaking history from
.git(which exposes commit messages, branch names, anything sensitive that was ever committed) - Bloating the build context (
node_modulescan be hundreds of megabytes) - Cache invalidation from irrelevant file changes
Run as non-root, even when you "don't have to"
Even if your runtime orchestrator enforces runAsNonRoot, set the user in the image. Defence in depth.
RUN addgroup --system --gid 1000 app && \
adduser --system --uid 1000 --ingroup app --no-create-home app
USER app:appFor distroless images, this is already done — pick the :nonroot variants:
FROM gcr.io/distroless/java21-debian12:nonrootCommon antipatterns I still see in 2026
A grab-bag of "please stop doing this":
FROM ubuntu:latest— pulls a fresh upstream every build, no reproducibility, larger than needed. Use a slim variant or distroless.RUN apt-get update && apt-get install ...without--no-install-recommendsand without cleaning the apt cache afterwards. Drops 30-100MB of fluff into the image.COPY . .at the top of the Dockerfile. Invalidates every cache layer on every commit.- Hardcoded
latesttags in production manifests. Tags move; production should reference digests. - Pushing to Docker Hub anonymously. The rate limits will bite you in CI. Use a private registry or pull-through cache. For managed customers we run a registry per environment on AWS ECR / GAR / ACR so this just isn't a concern.
What to do this week
If you take three things away:
- Add
# syntax=docker/dockerfile:1.7to every Dockerfile - Convert your single-stage Dockerfiles to multi-stage with
--mount=type=cache - Move to a distroless or
-slimbase for the runtime stage
These three changes will typically cut your image size by 60-80%, your CI build time by 50-70%, and your CVE count at scan time by an order of magnitude. They're a half-day of focused work and they keep paying back forever.
If you want help systematizing this across your container estate, that's exactly what our managed Docker engagements tend to start with.
Sudhanshu K. is a Staff DevOps engineer at EdgeServers (RemotIQ Pty Ltd, ABN 91 682 628 128). He has a personal grudge against 4GB Java application images.