Most Dockerfiles work. That's the problem. docker build succeeds, the container runs, and nobody looks at the file again until CI is taking eleven minutes per build, the image is 1.4 GB, or a security review asks why the app runs as root. None of those are exotic failures — they're the default outcome of a copy-pasted Dockerfile that was never held to production standards.
I review a lot of Dockerfiles, both in our own repos and in what users push to PandaStack's build pipeline. The same handful of issues account for almost all of the pain. Here's the checklist I actually use, roughly in order of impact.
Order layers by how often they change
Docker caches each instruction as a layer, and a cache miss invalidates every layer after it. So the single highest-leverage change in most Dockerfiles is copying dependency manifests before source code:
# Bad: any source change reinstalls all dependencies
COPY . .
RUN npm ci
# Good: dependencies only reinstall when the lockfile changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .The same pattern applies everywhere: go.mod/go.sum before go build, requirements.txt or pyproject.toml before pip install, Cargo.toml/Cargo.lock before cargo build. Your source changes on every commit; your lockfile changes weekly. Structure the file so the expensive step keys off the thing that rarely changes.
While you're at it, write a real .dockerignore. node_modules, .git, build output, .env files. Without it, COPY . . busts the cache whenever *anything* in the directory changes — including files that never belonged in the image. And an .env file that sneaks into an image layer is a leaked secret, full stop.
Multi-stage builds: build heavy, ship light
The toolchain that builds your app should not ship with it. Compilers, dev dependencies, and build caches belong in a build stage; the final stage gets only the artifacts:
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]For compiled languages the payoff is dramatic — a Go binary copied into a distroless base produces an image measured in tens of megabytes instead of a gigabyte:
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 /app ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
ENTRYPOINT ["/app"]Smaller images aren't just vanity: they pull faster on every node, cold-start faster, and carry fewer packages for CVE scanners to flag.
Choose a boring base image
My default advice:
-slimvariants (Debian-based) for interpreted languages. Nearly as small as Alpine without the surprises.- Alpine only if you know what musl libc means for your stack. Native Node modules, some Python wheels, and DNS edge cases have all burned people. When it works, it works — but "smaller by 40 MB" is not worth a 2 a.m. incident about a segfaulting native dependency.
- Distroless or scratch for static binaries. No shell, no package manager, minimal attack surface. The trade-off is debuggability — you can't
execinto a shell that doesn't exist.
Whatever you choose, pin it. node:20-slim is decent; node:20.11-slim is better; pinning by digest (node:20-slim@sha256:...) is the only version that's actually immutable, since tags can be re-pushed. Digest pins plus a bot to update them gives you reproducibility *and* patches.
Run as non-root
Containers share the host kernel. A container escape from a root process is a much worse day than one from an unprivileged user, and plenty of platforms (Kubernetes with restrictive admission policies, OpenShift by default) will refuse root containers outright.
# Node images ship a 'node' user already
USER node
# Elsewhere, create one
RUN useradd --system --uid 10001 --create-home appuser
USER appuserDo this in the final stage, after any RUN steps that need root (package installs). If the app needs to write, give it one explicit writable directory rather than loosening permissions broadly. This matters even when the platform adds its own isolation — PandaStack builds images with rootless BuildKit in ephemeral Kubernetes Job pods (no host Docker socket to leak) and runs free-tier workloads under gVisor, but defense in depth only works if your layer holds up too.
Use BuildKit's cache mounts and secrets
BuildKit has been the default builder for years now, and two of its features remain underused.
Cache mounts persist a directory across builds without baking it into a layer — ideal for package manager caches:
RUN --mount=type=cache,target=/root/.npm \
npm ci
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txtSecret mounts expose credentials during a single RUN step without ever writing them to a layer:
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ciNever pass secrets as ARG or ENV — build args are recorded in image history and visible to anyone who can pull the image. docker history on a random registry image is an educational exercise I recommend exactly once.
Reproducible builds
A Dockerfile that produces a different image depending on the day it runs is a debugging liability. The offenders are always the same:
- Unpinned installs.
apt-get install curltoday is notapt-get install curlnext month. Pin versions where the risk justifies the maintenance, and alwaysrm -rf /var/lib/apt/lists/*in the sameRUNto keep the layer small. latesttags. Anywhere. Base image, sidecar references, anything.- Ignoring the lockfile.
npm installin a Dockerfile is a bug;npm ciis the correct command — it installs exactly what the lockfile says or fails loudly. Same idea:pip installfrom a compiledrequirements.txtwith hashes,go mod downloadagainstgo.sum. - Network fetches with no checksum. If a
RUNstep curls a tarball, verify its SHA256.
Signals, PID 1, and the exec form
Use the exec form of CMD/ENTRYPOINT — CMD ["node", "server.js"], not CMD node server.js. The shell form wraps your process in /bin/sh, which swallows SIGTERM, which means your app never gets a graceful-shutdown signal and gets SIGKILLed after the platform's grace period. Every mysteriously dropped in-flight request during deploys traces back to this or to the app not handling SIGTERM at all.
If your process spawns children or you're stuck with something that can't reap zombies, add an init: docker run --init, or tini in the image. For a typical single-process Node/Go/Python server, exec form plus a signal handler is enough.
The five-minute audit
Next time you touch a Dockerfile, check: lockfile copied before source; multi-stage with a slim final image; pinned base; USER set; .dockerignore present; no secrets in ARG/ENV; exec-form CMD. That's the 20% that prevents 80% of the incidents.
Worth bookmarking: Docker's own [building best practices](https://docs.docker.com/build/building/best-practices/), the [multi-stage build docs](https://docs.docker.com/build/building/multi-stage/), the [cache optimization guide](https://docs.docker.com/build/cache/), and the [Dockerfile reference](https://docs.docker.com/reference/dockerfile/) for the flags people forget exist.
And if you'd rather push a repo and let a build pipeline apply the secure-by-default parts for you — rootless builds, no Docker socket, sandboxed runtime — you can try exactly that on https://pandastack.io.