Understanding Dockerfile best practice
Each line is a layer, each layer is a cost.
What the build cache actually caches, why every guide says "multi-stage", and the smallest changes that take an image from 1.4 GB to 90 MB.
One Dockerfile, many layers.
Each instruction (FROM, COPY, RUN, ENV, CMD) produces a new image layer. Layers are content-addressed: change a single byte in a COPYed file and every subsequent layer rebuilds. The order of instructions therefore matters enormously — put the slowest- changing things at the top so the cache survives most changes. The classic mistake is COPY . /app as the second line: every edit anywhere in the repo invalidates everything below.
The lockfile-first pattern.
For language images, the dependency install should happen before the source copy. Copy only the manifest and lockfile, run the installer, then copy the rest of the source. Now the slow installer step is cached on lockfile content — code edits don't reinvalidate it. For Node: COPY package.json package-lock.json ./, then RUN npm ci, then COPY . .. For Python with Poetry: COPY pyproject.toml poetry.lock ./, install, then COPY . .. Same idea everywhere.
Multi-stage builds.
A multi-stage build uses multiple FROM lines, each starting a new stage. The final image only contains the contents of the last stage — earlier stages exist to produce artifacts that get copied across. The standard pattern: a heavy builder stage with compilers, dev dependencies and build toolchains; a slim runtime stage with just the runtime and the compiled output. The runtime image ends up dramatically smaller and contains no compiler or source code — improving startup, transfer time and attack surface.
A worked multi-stage Node Dockerfile.
# build stage
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# runtime stage
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev
USER node
CMD ["node", "dist/index.js"]
Build runs in a full Node image with dev dependencies; runtime runs in alpine with just production dependencies. The runtime image lacks compilers, lacks source code, and runs as a non-root user.
Image size comparison
node:20 (~1.1 GB) vs node:20-alpine (~150 MB)
The alpine base + multi-stage filtering halves the image at least.
1.1 GB build + 150 MB runtime (only runtime ships)
= ~150 MB delivered image
The .dockerignore file.
Without a .dockerignore, the entire build context — including node_modules, .git, build outputs, IDE files — is uploaded to the daemon every time. That's slow, leaks history, and bloats layers. A sane .dockerignore excludes node_modules, .git, .next / dist, Dockerfile, .env*, OS junk. This single file routinely doubles build speed on existing projects.
Don't run as root.
By default containers run as root. Anyone who breaks out of the application (perhaps via a known CVE in a dependency, perhaps via a logic bug) now has root inside the container — and depending on how the runtime is configured, possibly outside it too. Switching to a non-root user with USER node (or a freshly created user) eliminates the easy path. Some processes need to bind to low ports; the answer is almost never "stay as root" but "configure your reverse proxy to use a high port".
Pin the base image.
FROM node:20 grabs whatever 20 currently points at — that tag is mutable. Bumps to it can ship breaking changes (a new Debian base, a security patch that breaks an extension). FROM node:20.11.1-bookworm-slim is far more reproducible. For maximum reproducibility, pin by digest: FROM node:20.11.1@sha256:.... Pick the level appropriate for the project; long-lived production images should pin to digests, dev images can stay on tags.
The smallest image isn't always best.
Distroless and scratch images sound great until you need to debug. No shell, no package manager, no curl — fine in production, awful for triage. The recommended middle ground for most applications is alpine (musl libc, small but functional) or slim Debian variants (glibc, bigger but compatible with most native modules). Save distroless for things that genuinely benefit from it (security boundary services, extremely constrained edge deployments).