Back to Blog
Guide6 min read2026-07-13

Production Dockerfile Best Practices for 2026

Layer ordering, multi-stage builds, non-root users, BuildKit cache mounts, and reproducible builds — the Dockerfile habits that actually matter in production.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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:

  • -slim variants (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 exec into 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 appuser

Do 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.txt

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

Never 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 curl today is not apt-get install curl next month. Pin versions where the risk justifies the maintenance, and always rm -rf /var/lib/apt/lists/* in the same RUN to keep the layer small.
  • latest tags. Anywhere. Base image, sidecar references, anything.
  • Ignoring the lockfile. npm install in a Dockerfile is a bug; npm ci is the correct command — it installs exactly what the lockfile says or fails loudly. Same idea: pip install from a compiled requirements.txt with hashes, go mod download against go.sum.
  • Network fetches with no checksum. If a RUN step curls a tarball, verify its SHA256.

Signals, PID 1, and the exec form

Use the exec form of CMD/ENTRYPOINTCMD ["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.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Guide

Browse all Guide articles →

See also