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
- The Docker client sends the Dockerfile and build context to the builder. Files excluded by
.dockerignoreare not sent, so they cannot be copied accidentally. - Docker resolves the pinned
FROMimage and pulls any missing base layers. A tag such asnode:20-alpineis more predictable thannode:latest, though digest pinning is even stricter for high-control environments. - 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.
- When a
COPYorRUNinstruction changes, that layer and every layer after it are rebuilt. This is why dependency installation belongs before broad source copies. - In a multi-stage build, each
FROMstarts a separate stage. Only files explicitly copied withCOPY --from=buildermove into the final image. - 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
USERandCMD, 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
.dockerignoreto 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
COPYoverADDunless you specifically needADDfeatures 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
CMDandENTRYPOINTso 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
EXPOSEis documentation and metadata; publish ports with-por Composeports:.
Practice Exercises
- Take a Node Dockerfile that runs
COPY . .beforenpm ci. Reorder it so dependency installation is cached untilpackage.jsonorpackage-lock.jsonchanges. - 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.
- Write a
.dockerignorefor 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
latestfor 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. EXPOSEdocuments a container port but does not publish it to the host.
