Your Dockerfile probably works. It's also about five years out of date.

"The most dangerous phrase in the language is: we've always done it this way." — Grace Hopper

Containers are the smallest unit of modern infrastructure and the one nobody revisits. A Dockerfile gets written once, in a hurry, by whoever was unlucky enough to be on the ticket. It works. It gets copied into the next repo. Five years later a team of fifteen people is paying for a two-gigabyte image and a four-minute CI build because of a decision someone made in an afternoon and nobody ever questioned.

The awkward part is that the advice most of us are working from is genuinely old. BuildKit has been the default builder for years and most Dockerfiles in the wild use none of it. Meanwhile the base-image landscape shifted underneath everyone in December 2025 and a lot of "best practices" posts haven't caught up.

So this is the three-tier version: the hygiene everyone half-knows, the BuildKit features almost nobody uses, and the supply-chain layer that's stopped being optional.

Tier 1: the hygiene everyone half-knows

Alpine is not automatically the right answer

The reflex is FROM python:3.13-alpine because it's small. Alpine is small because it ships musl as its C library instead of glibc, and that swap is the whole story.

Almost everything you install has native code underneath it eventually calling into libc. The Python and Node ecosystems distribute prebuilt binaries — manylinux wheels, node-gyp prebuilds — and those are built against glibc. On Alpine they don't match, so your package manager falls back to compiling from source. Which is why so many Alpine Dockerfiles carry a line like this:

RUN apk add --no-cache build-base

That's a C toolchain in your build because the ecosystem's binaries didn't fit. You pay for it on every cold build, in every pipeline, forever. The -slim variants are Debian-based, glibc-compatible, and cost something like 90MB over Alpine. That is a rounding error against the recompilation you just stopped doing.

The nuance worth keeping: this is a Python and Node problem, not a universal one. A statically linked Go binary doesn't care. Rust targeting x86_64-unknown-linux-musl is often deliberately choosing musl. If your build produces one self-contained binary with no native dependency tree, Alpine is fine and the argument doesn't apply to you.

One deadline worth putting in your calendar: Debian bullseye's security updates end on 31 August 2026. If anything you own still says bullseye, that's now a migration ticket rather than a preference.

Layer order is a cache strategy, not a formatting choice

A build is a chain. Every instruction produces a layer keyed by its inputs, and changing one invalidates it and everything after it — a wave that travels to the end of the file. Order your instructions so that the things that change most often live at the bottom, where the wave is shortest.

# Slow: any code edit reinstalls every dependency
COPY . .
RUN npm ci

# Fast: dependencies only reinstall when the manifests change
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

With multi-stage builds it's really a DAG rather than a chain — each stage is its own sequence, joined by COPY --from edges — so a change only propagates into the stages that actually depend on it. That's a big part of why multi-stage is worth it even when you don't care about final image size.

COPY . . isn't the problem. Your build context is.

There's a folk rule that COPY . . is bad and you should enumerate every path explicitly. That produces the forty-line copy incantations you've seen, and it doesn't fix anything. The actual problem is shipping junk into the build context in the first place — node_modules, .git, build artifacts, logs, .env files.

.dockerignore works exactly like .gitignore and solves it in one file:

node_modules
.git
*.log
dist
.env*

Once the context is clean, COPY . . is readable and correct, and your build stops transferring a few hundred megabytes it's about to throw away.

Multi-stage, and knowing where to stop

The Go example makes the point better than prose does. Same program, three approaches:

Approach Image size
golang:alpine, run the source ~272 MB
Multi-stage, copy binary into alpine ~11 MB
Multi-stage, copy binary into scratch ~2.3 MB
FROM golang:1.24 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
ENTRYPOINT ["/app"]

scratch wins on size and loses on everything else — no shell, no CA certificates, no /tmp, no timezone data, no user accounts. Distroless is the pragmatic middle: still tiny, still no shell or package manager, but with the runtime basics and sane defaults you'll actually miss at 3am. Pick scratch when you've genuinely verified the binary needs nothing; pick distroless the rest of the time.

Tier 2: the BuildKit features nobody uses

Everything above is 2018 advice executed properly. This is the part that's actually changed.

Cache mounts

This is the single highest-leverage line you can add to a Dockerfile. Layer caching helps when nothing changed; cache mounts help when something did.

# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

The cache directory lives outside the image, persists across builds on the host, and never lands in a layer. Change one dependency and you re-download one dependency instead of four hundred. It survives --no-cache image builds, because it isn't image cache. For most projects this is the difference between a thirty-second iteration and a four-minute one.

The equivalents:

Ecosystem Cache target
pip /root/.cache/pip
npm /root/.npm
Go modules /go/pkg/mod and /root/.cache/go-build
Cargo /usr/local/cargo/registry and target/
apt /var/cache/apt and /var/lib/apt (drop --no-cache / the rm -rf dance)

Bind mounts instead of COPY for dependency resolution

If a file is only needed during a step, it doesn't need to become a layer:

RUN --mount=type=bind,source=go.mod,target=go.mod \
    --mount=type=bind,source=go.sum,target=go.sum \
    go mod download

No layer, no invalidation surface, nothing left in the image.

Secret mounts

If you've ever done this, and most of us have:

ARG NPM_TOKEN          # don't
RUN npm ci

…the token is now in the image history, permanently, for anyone who pulls it. The correct version:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc .

The file exists for the duration of that one instruction and is never written to a layer. There's a matching --mount=type=ssh for private Git dependencies, so you can stop baking deploy keys into images too.

Remote cache in CI

CI runners are ephemeral. Local layer cache is worthless there unless you export it somewhere:

docker buildx build \
  --cache-from type=gha \
  --cache-to type=gha,mode=max \
  --push -t ghcr.io/me/app:latest .

Two things bite people here. mode=max exports every intermediate layer rather than just the final ones — much faster rebuilds, far more storage, and GitHub's cache is capped at 10GB per repository with least-recently-used eviction. And if you build a matrix, give each variant its own cache scope, or they overwrite each other and every build looks like a cold start. For self-hosted setups, type=registry against your own registry avoids the size cap entirely.

Bake, when one docker build stops being enough

Once you're building several images with shared arguments and multi-platform targets, the command line becomes unmaintainable and starts drifting from what CI runs. docker buildx bake moves it into a file:

target "api" {
  dockerfile = "Dockerfile"
  platforms  = ["linux/amd64", "linux/arm64"]
  tags       = ["ghcr.io/me/api:latest"]
  cache-from = ["type=gha,scope=api"]
  cache-to   = ["type=gha,scope=api,mode=max"]
}

Targets build in parallel, inherit from each other, and — the actual win — local and CI run the identical definition, so build logic can't quietly diverge between them.

PID 1, and why your containers take ten seconds to stop

This one is everywhere and it's a one-line fix.

CMD node server.js          # becomes /bin/sh -c "node server.js"
CMD ["node", "server.js"]   # node is PID 1

Shell form puts /bin/sh at PID 1, and sh doesn't forward signals to its children. docker stop sends SIGTERM, the shell ignores it, your app never hears about it, and ten seconds later the daemon gives up and sends SIGKILL. No graceful shutdown, no draining connections, no flushing writes.

Exec form fixes the delivery, but there's a second half people miss: PID 1 has no default signal handlers. The kernel deliberately won't kill init by accident, so if your app has no explicit SIGTERM handler the signal is discarded anyway. Exec form gets the signal to the door; your application still has to answer it.

If your entrypoint is a shell script, exec the real process at the end so it inherits PID 1. If you're spawning children and want zombie reaping handled for you, add tini or run with --init (init: true in Compose).

The test takes two seconds:

time docker stop <container>

Anything over about three seconds means something in that chain is broken.

Tier 3: the supply-chain layer

This is where container work stops being a performance exercise.

Pin digests, but automate the bumps

Tags move. node:22-slim today is not necessarily node:22-slim next Tuesday, whether by intent, accident, or compromise. The permanent identifier is the digest:

docker buildx imagetools inspect node:22-slim
FROM node:22-slim@sha256:...

The catch nobody mentions: a pinned digest also stops receiving base-image CVE patches. A digest pin without automation is a security regression with about a three-month fuse. Pin it, then let Renovate or Dependabot raise the bump PRs. The pin gives you reproducibility; the bot gives you patching. You need both.

Attestations

BuildKit will produce an SBOM and SLSA provenance for you, and it's genuinely two flags:

docker buildx build --sbom=true --provenance=mode=max --push -t ghcr.io/me/app .

Provenance records how the image was built — base images consumed, build arguments, source mapping back to the Dockerfile. The SBOM records what's inside it. Both attach to the image index rather than the image itself, so they can be inspected without pulling the whole thing:

docker buildx imagetools inspect ghcr.io/me/app --format "{{json .Provenance.SLSA}}"

The value shows up on the day a CVE lands in a transitive dependency and someone asks which of your forty images ship it. That's a query against your SBOMs, or it's an afternoon of docker run --rm image sh and guessing.

Reproducible builds, honestly

You can get most of the way with SOURCE_DATE_EPOCH, which normalises timestamps in the image config and history. To also normalise the timestamps on files inside the layers you need the exporter option, which is off by default because rewriting layers costs real time:

SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) docker buildx build \
  --output type=image,name=ghcr.io/me/app,push=true,rewrite-timestamp=true .

Be realistic about what this buys you. Timestamps are the easy source of nondeterminism. apt-get install resolving to whatever's current that day is the hard one, and fixing it properly means pinning against snapshot.debian.org. Alpine is harder still because apk has no snapshot infrastructure. Bit-for-bit reproducibility is a real goal but it's a project, not a flag.

Hardened base images are now free

This is the bit that genuinely changed recently. Docker launched Hardened Images as a commercial product in May 2025, and on 17 December 2025 open-sourced the entire catalogue — over a thousand images built on Debian and Alpine — under Apache 2.0.

They're non-root by default, stripped of shells and package managers, ship complete SBOMs and SLSA Build Level 3 provenance, carry VEX data so scanners can tell you which CVEs are actually exploitable in context, and come with a commitment to ship fixes within seven days of disclosure.

FROM dhi.io/node:24-dev AS build
# ...
FROM dhi.io/node:24

Chainguard's Wolfi-based images occupy similar ground and generally benchmark well on CVE counts, because continuous rebuilds beat Debian's point-release cadence — Google's Distroless, despite its reputation, inherits Debian stable's patch latency and often scores worse than either. But the practical answer for most people changed in December: there is now a free, signed, near-zero-CVE base tier, and "hardened images are an enterprise thing" is no longer true.

The runtime half

A perfect Dockerfile still runs with more privilege than it needs unless you say otherwise. This belongs in your Compose file or pod spec, not the image:

services:
  api:
    image: ghcr.io/me/api@sha256:...
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE]   # only if you actually bind <1024
    security_opt:
      - no-new-privileges:true
    init: true
    deploy:
      resources:
        limits: { cpus: '0.5', memory: 256M, pids: 100 }

None of these are individually clever. They compound: a non-root process, on a read-only filesystem, with no capabilities and no path to acquiring any, is a genuinely miserable place for an attacker to land. The Kubernetes equivalents are runAsNonRoot, readOnlyRootFilesystem, allowPrivilegeEscalation: false, capabilities.drop: [ALL], and seccompProfile: RuntimeDefault.

COPY --link writes its content as an independent layer rather than one stacked on the previous state, which in theory means the layer survives changes below it and can be reused via --cache-from. Docker's own documentation recommends it particularly for multi-stage builds, where a COPY --from would otherwise be invalidated by any earlier command in the same stage.

The counter-argument comes from Depot, who build containers for a living and found it more often increases build times in practice, citing performance problems in how BuildKit handles it and cache garbage-collection bugs where --link artifacts get accounted as zero bytes and aren't invalidated correctly.

There are also concrete behavioural differences: --link can't follow a destination symlink created in an earlier layer, and its layers hold the full content at the destination rather than a diff, so repeatedly copying the same large directory duplicates data.

My read: it's not a default. It's a thing to reach for when you have a specific, measured multi-stage cache-invalidation problem — and then to actually measure, because the theory and the field reports disagree and only your build knows which is right.

What this actually gets you

None of this is exotic. The individual pieces are documented, most are one line, and the hardest thing about any of them is remembering they exist while you're mid-ticket and the pipeline is red.

The composition is what matters. Correct layer order and a clean context make the cache work at all. Cache mounts make it work when the cache misses. Multi-stage and a minimal base decide what ships. Digest pins plus a bot decide whether it stays patched. Attestations decide whether you can answer questions about it in six months. Runtime hardening decides how much it matters when something does go wrong.

If you only take three things:

  1. Add a cache mount to your dependency install step. Today. It's one line and you'll feel it immediately.
  2. Run time docker stop on something in production and see whether it's actually shutting down or just being killed.
  3. Look at what your base image would be if you moved to a hardened one, now that it costs nothing.

The rest is negotiable. Just don't leave it the way it is because that's how it's always been done.