Saltar al contenido
EdgeServers
Blog

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.

19 de mayo de 2026 · 8 min · por 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.7

That 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=cache persists dependency caches between builds. For Go, this saves go mod download and the build cache. For Node, it saves node_modules. For Python, it saves pip cache. For Rust, it saves target/. 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:nonroot baked in at the image level. No runAsUser needed 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 build

Get 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 ci

Then 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:latest

If 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-essential in the runtime stage.
  • Carrying test data into runtime. Use .dockerignore aggressively.
  • Default base image too large. python:3.12 is 900MB; python:3.12-slim is 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:f11725aba18c19664a408902103365eaf8013823ffc56270f921d1dc78a198cb

Yes, 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 1

The 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_modules can 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:app

For distroless images, this is already done — pick the :nonroot variants:

FROM gcr.io/distroless/java21-debian12:nonroot

Common 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-recommends and 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 latest tags 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:

  1. Add # syntax=docker/dockerfile:1.7 to every Dockerfile
  2. Convert your single-stage Dockerfiles to multi-stage with --mount=type=cache
  3. Move to a distroless or -slim base 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.