A Dockerfile that builds and a container that runs are a low bar — plenty of Dockerfiles clear it while still producing images that are slow to build, needlessly large, or quietly insecure. None of the practices below are exotic; they're the difference between a Dockerfile someone wrote once to get something working and one that's still cheap to build and safe to run a year later.
Order instructions from least to most frequently changing
Docker caches each layer and reuses it on rebuild as long as that instruction and everything before it are unchanged. Copying your entire source tree before installing dependencies means every code change invalidates the dependency-install layer too — so every build reinstalls everything from scratch:
# Cache-hostile: any source change reinstalls all dependencies
COPY . .
RUN npm install
# Cache-friendly: dependencies only reinstall when package.json changes
COPY package.json package-lock.json ./
RUN npm install
COPY . .The dependency manifest changes rarely; your source changes constantly. Put the rarely-changing layer first.
Use multi-stage builds to separate building from running
A build usually needs a compiler, dev dependencies, and build tools that the running container never needs again. A multi-stage build keeps those out of the final image entirely — only the artifacts you explicitly COPY --from the earlier stage make it into the last one:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/index.js"]The final image never contains the TypeScript compiler, dev dependencies, or your test suite — just what's needed to run.
Pin a real tag, not :latest
FROM node:latest (or a bare FROM node, which means the same thing) means the base image your build resolves to today can be a different image tomorrow. A build that worked last week can start failing — or behaving differently — with no change to your own files. Pin a specific version:
FROM node:20.11-alpineCombine update and install, and clean up in the same layer
If apt-get update and apt-get install land in separate RUN instructions, Docker's layer cache can reuse a stale update layer on rebuild while your install layer changes — installing against a package index that's no longer current. Keep them together, and clean up the package cache in the same layer so it doesn't linger in the image:
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*Cleaning up in a later, separate RUN doesn't shrink the image — the earlier layer that added those files is still part of the image's history regardless of what a later layer deletes.
Add a .dockerignore
Without one, COPY . . sends everything in the build context to the daemon — including node_modules, .git, and any local .env file sitting in the project root — which both slows the build and risks copying secrets or bulky local state into an image layer:
node_modules
.git
.env
*.log
distDon't run as root
With no USER instruction, a container runs as root by default. If the application inside is ever compromised, root inside the container is a meaningfully worse starting point than an unprivileged user — especially combined with any container misconfiguration that weakens the isolation between container and host:
RUN addgroup -S app && adduser -S app -G app
USER appMany official images (like node) already ship a non-root user you can use directly — USER node — without creating one yourself.
Prefer COPY over ADD
ADD does extra, easy-to-forget things: it fetches remote URLs, and it auto-extracts local archive files. Neither behavior is obvious from reading a Dockerfile casually. COPY does exactly one thing — copy files — and reserving ADD for the rare case that genuinely needs its extra behavior makes both instructions mean what they look like they mean.
Never put secrets in an ARG or a layer
A value passed via ARG and used in a RUN command doesn't disappear once the build finishes — it's recoverable from the image's build history (docker history) even if a later instruction deletes the file that used it. The same is true of anything written to a file and removed in a later layer: the earlier layer still contains it. For build-time secrets, use BuildKit's dedicated secret mount instead, which never gets written to a layer:
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm installdepends_on starts containers in order — it doesn't wait for readiness
In a docker-compose.yml, depends_on: [db] guarantees the db container starts before the dependent service's container starts. It does not wait for Postgres inside that container to actually be accepting connections — those are different moments, and the gap between them is a common source of flaky "works on the second try" startup failures. To wait for real readiness, add a HEALTHCHECK to the dependency and a condition on the consumer:
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
api:
build: .
depends_on:
db:
condition: service_healthyTry it yourself
Check a Dockerfile against several of these directly in Dockerfile Linter — it flags unpinned tags, apt-get install without a combined update, ADD where COPY would do, and a final stage with no USER. For the compose side, Docker Compose Validator catches undefined depends_on references and undeclared volumes/networks, and Docker Compose Visualizer shows the computed start order so you can see at a glance which services actually wait on which. All of it runs entirely in your browser.