Dockerfile Best Practices

Dockerfile best practices help you build images that are smaller, faster to rebuild, easier to debug, and safer to run. A Dockerfile is not just a list of setup commands; each instruction affects image layers, build cache behavior, runtime metadata, and what gets shipped to production.

The best Dockerfiles are boring in a good way: they pin versions, copy files in cache-friendly order, keep build tools out of the final image, run as a non-root user, and avoid baking secrets into layers. This lesson shows the patterns that matter most in real projects.

Overview: How it works

Docker builds an image by reading a Dockerfile from top to bottom. Most filesystem-changing instructions, especially RUN, COPY, and ADD, create read-only image layers. The final image is a stack of those layers plus a configuration object containing metadata such as CMD, ENTRYPOINT, ENV, WORKDIR, exposed ports, and the default user.

When you run a container, Docker does not copy the whole image. It mounts the image’s read-only layers and adds a thin writable layer for that container. This is why image design matters: every unnecessary file in the image must be pulled, stored, scanned, and mounted later. It is also why deleting a secret in a later RUN instruction does not truly remove it from an earlier layer.

The build cache is central to Dockerfile quality. Docker can reuse the result of an instruction when that instruction and its inputs have not changed. If a layer changes, every layer after it must be rebuilt. Good Dockerfiles put slow, rarely changing steps before fast, frequently changing steps. For example, copy dependency manifests and install packages before copying application source. Then editing server.js does not force Docker to reinstall every dependency.

BuildKit, the modern Docker builder, improves caching, parallelism, and features such as cache mounts and secret mounts. You can write excellent Dockerfiles with the classic instruction set, but BuildKit-era features are useful when they keep package caches out of final layers or pass sensitive values into a build without storing them in the image.

Syntax

FROM pinned-base-image:version
WORKDIR /app
COPY dependency-files ./
RUN install-dependencies
COPY source-files ./
USER nonroot-user
EXPOSE container-port
CMD ["executable", "arg"]
Practice Typical instruction Why it matters
Pin versions FROM node:20-alpine Avoids the moving target of latest and makes builds more reproducible.
Set a working directory WORKDIR /app Makes later relative paths predictable and avoids fragile cd chains.
Optimize cache order COPY package*.json ./ before COPY . . Keeps dependency layers reusable when source files change.
Use multi-stage builds COPY --from=builder ... Ships only runtime artifacts, not compilers, caches, or build tools.
Run as non-root USER appuser Limits damage if the application process is compromised.
Document ports EXPOSE 3000 Records intended container ports, but does not publish them to the host.

There is no single perfect Dockerfile for every application. A Python API, a Node web app, a Go binary, and an Nginx static site have different dependency and runtime needs. The consistent rule is to make each layer intentional: know why it exists, how often it changes, and whether it belongs in the final image.

Examples

Example 1: Cache-friendly Node Dockerfile

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]

Output:

#1 [internal] load build definition from Dockerfile
#2 [1/6] FROM docker.io/library/node:20-alpine
#3 [3/6] COPY package.json package-lock.json ./
#4 [4/6] RUN npm ci --omit=dev
#5 [5/6] COPY server.js ./
#6 exporting to image
#6 naming to docker.io/library/node-api:1.0 done

This Dockerfile pins node:20-alpine instead of using node or latest. It copies dependency files before application source, so the expensive npm ci layer can be reused when only server.js changes. EXPOSE 3000 documents the container port only; users still need docker run -p 8080:3000 node-api:1.0 to publish it on the host.

Example 2: Multi-stage build for a small Go image

FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /out/api ./cmd/api

FROM alpine:3.20
RUN addgroup -S app && adduser -S -G app app
WORKDIR /app
COPY --from=builder /out/api ./api
USER app
EXPOSE 8080
CMD ["./api"]

Output:

#4 [builder 4/6] RUN go mod download
#6 [builder 6/6] RUN go build -o /out/api ./cmd/api
#8 [stage-1 3/5] COPY --from=builder /out/api ./api
#10 exporting to image
#10 naming to docker.io/library/go-api:1.0 done

The first stage contains the Go compiler and module cache. The second stage starts fresh from alpine:3.20 and copies only the compiled binary. That keeps the shipped image smaller and reduces the amount of software that has to be scanned and patched. The final container runs as app, not root, which is a safer default for production services.

Example 3: Keep the build context small

node_modules
.git
coverage
.env
Dockerfile
README.md

Output:

#1 transferring context: 28.45kB
#1 DONE 0.1s

This is a typical .dockerignore file. The build context is the directory content sent to the builder so COPY can use it. Without a good ignore file, Docker may send local dependencies, test output, Git history, and accidental secrets. Smaller contexts make builds faster and reduce the chance that unwanted files become part of an image layer.

Example 4: Build with an explicit tag

docker build -t node-api:1.0 .
docker run --rm -p 8080:3000 node-api:1.0

Output:

Server listening on port 3000

The image tag node-api:1.0 gives you a stable thing to deploy, test, and roll back. The run command publishes host port 8080 to container port 3000. The Dockerfile’s EXPOSE line did not do that by itself.

How it works step by step

  1. The Docker client sends the Dockerfile and build context to the builder. Files excluded by .dockerignore are not sent, so they cannot be copied accidentally.
  2. Docker resolves the pinned FROM image and pulls any missing base layers. A tag such as node:20-alpine is more predictable than node:latest, though digest pinning is even stricter for high-control environments.
  3. For each instruction, Docker checks whether it can reuse a cached layer. The instruction text, previous layer, build arguments used by the step, and copied file contents all affect the cache key.
  4. When a COPY or RUN instruction changes, that layer and every layer after it are rebuilt. This is why dependency installation belongs before broad source copies.
  5. In a multi-stage build, each FROM starts a separate stage. Only files explicitly copied with COPY --from=builder move into the final image.
  6. At the end, Docker stores the image layers and config metadata. When a container starts, Docker mounts those read-only layers, adds a writable layer, applies metadata such as USER and CMD, then starts the process.

Common Mistakes

Using latest in production

FROM node:latest
WORKDIR /app
COPY . .
CMD ["node", "server.js"]

This is wrong because latest can point to a different image tomorrow. A rebuild may silently pick up a new operating system package set, a new Node major version, or a breaking change. Use a specific tag:

FROM node:20-alpine
WORKDIR /app
COPY . .
CMD ["node", "server.js"]

Invalidating the whole cache

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]

Here any source edit changes the COPY . . layer, so npm ci must run again. Copy dependency manifests first, install dependencies, then copy the rest of the source:

FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]

Baking secrets into image layers

FROM alpine:3.20
ENV API_TOKEN=hardcoded-secret
RUN echo "ready" > /status.txt

This is unsafe because ENV values are image metadata, and secrets written in one layer can remain recoverable even if a later layer removes the file. Use runtime configuration, Docker secrets, or orchestrator-managed secrets instead. For local examples, pass an obvious placeholder at run time:

docker run --rm -e "API_TOKEN=<YOUR_API_TOKEN>" myapp:1.0

Running as root by default

FROM alpine:3.20
WORKDIR /app
COPY app.sh ./app.sh
CMD ["sh", "app.sh"]

Many base images default to root. That may be convenient during builds, but the runtime process should usually have fewer privileges:

FROM alpine:3.20
RUN addgroup -S app && adduser -S -G app app
WORKDIR /app
COPY app.sh ./app.sh
RUN chown app:app app.sh
USER app
CMD ["sh", "app.sh"]

Best Practices

  • Pin base image tags, and consider digest pinning when you need exact byte-for-byte reproducibility.
  • Use .dockerignore to exclude local dependencies, Git history, test output, credentials, and editor files from the build context.
  • Put slow, rarely changing dependency steps before frequently changing source-code copies.
  • Prefer COPY over ADD unless you specifically need ADD features such as local archive extraction.
  • Use multi-stage builds to keep compilers, package caches, and source-only build artifacts out of production images.
  • Run the final image as a non-root user whenever the application does not require root privileges.
  • Use exec-form CMD and ENTRYPOINT so the application receives signals directly.
  • Do not store passwords, tokens, private keys, or real credentials in ARG, ENV, files copied into the image, or build logs.
  • Keep images focused on one main process. Use Compose or an orchestrator to run databases, workers, and web services together.
  • Remember that EXPOSE is documentation and metadata; publish ports with -p or Compose ports:.

Practice Exercises

  1. Take a Node Dockerfile that runs COPY . . before npm ci. Reorder it so dependency installation is cached until package.json or package-lock.json changes.
  2. Create a multi-stage Dockerfile for a compiled application. The final stage should contain only the runtime base image, the compiled artifact, a non-root user, and the default command.
  3. Write a .dockerignore for a project that has .git, node_modules, coverage, .env, and local log files. Expected end state: none of those files are sent in the build context.

Summary

  • Good Dockerfiles are designed around layers, cache invalidation, and runtime metadata.
  • Pin base images instead of relying on latest for production builds.
  • Copy dependency manifests before source code to preserve expensive dependency-install layers.
  • Use multi-stage builds to ship only what the application needs at runtime.
  • Use .dockerignore, non-root users, and runtime secret handling to reduce image risk.
  • EXPOSE documents a container port but does not publish it to the host.