Dockerfile Best Practices, Each One Built and Measured
Install dependencies before copying source, and a one-line change rebuilds in 0.54s instead of 4.81s. Add a .dockerignore, and the build context drops from 76.63MB to 37.29kB while your .env stops shipping. Clean up in the same RUN or a separate stage, because an apk del in a later layer left a 336MB image. Pass secrets as mounts, not ARG. Run as non-root and pin the base. And exec form alone did not make docker stop clean: the app at PID 1 still needed a SIGTERM handler or --init.
Most lists of Dockerfile best practices are the same list, stated without a single number. This one builds a small image for each rule and keeps only what the measurement supports. One rule turned out to be half true, and the missing half is the one that bites in production.
Everything below ran on Docker Engine 29.7.2 (Docker Desktop 4.90.0, BuildKit through buildx 0.36.1) on a busy arm64 Mac, so build times are medians of five runs. Sizes are the DISK USAGE column of docker images or the layer figure from docker history. The apps are tiny — Node 22, Python 3.13, a C file on Alpine 3.22 — because the rules are about the Dockerfile, not the language. For Java with its own numbers, see how to dockerize a Spring Boot application.
Install dependencies before you copy the source
The Dockerfile most people write first:
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev --no-audit --no-fund
CMD ["node", "server.js"]
The app depends on five packages, which npm ci resolves to 82 and a 54MB layer. I changed one string in server.js and rebuilt, five times, with a warm cache each time:
| Order | Median rebuild | Steps CACHED |
Rebuilt layer |
|---|---|---|---|
COPY . . then npm ci |
4.81s | 1 | 54MB |
lockfile, npm ci, then COPY . . |
0.54s | 3 | 57.3kB |
The fix moves one line and adds one:
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY . .
CMD ["node", "server.js"]
The build log for the second order says what happened:
#7 [3/5] COPY package.json package-lock.json ./
#7 CACHED
#8 [4/5] RUN npm ci --omit=dev --no-audit --no-fund
#8 CACHED
#9 [5/5] COPY . .
#9 DONE 0.0s
A layer is reused only when its instruction and every input before it are unchanged, so COPY . . makes the install depend on every source file. Both images were 297MB; the order changes what gets rebuilt, not what gets shipped.
Add a .dockerignore before the first build
Put the same project in a directory that also has a local node_modules (41MB), a .git directory (40MB) and a .env holding a fake DATABASE_PASSWORD=not-a-real-secret-123. Build it with the good Dockerfile above, with no .dockerignore:
#5 transferring context: 76.63MB 0.9s done
docker run --rm dfbp-ignore-without cat /app/.env
DATABASE_PASSWORD=not-a-real-secret-123
The final COPY . . layer is 84.7MB, the host's node_modules overwrote the one npm ci installed, and anyone who can pull the image can read the password.
Five lines fix all three problems:
node_modules
.git
.env
Dockerfile
.dockerignore
Each variant was built three times from a fresh copy of the directory, because BuildKit transfers context incrementally and a repeat build under-reports. Every run without the file sent 76.63MB; every run with it sent 37.29kB. The COPY . . layer fell to 12.3kB, and cat /app/.env now fails with No such file or directory.
The build context docs sell this as speed; the stronger argument here is the secret that stopped shipping.
Clean up in the same RUN, or in another stage
This looks like responsible cleanup:
FROM alpine:3.22
RUN apk add --no-cache build-base
COPY hello.c .
RUN gcc -O2 -o /usr/local/bin/hello hello.c
RUN apk del build-base
CMD ["hello"]
The compiler is gone — which gcc exits 1 inside the container — and the image is 336MB. docker history shows why:
81.9kB RUN /bin/sh -c apk del build-base # buildkit
90.1kB RUN /bin/sh -c gcc -O2 -o /usr/local/bin/hel…
8.19kB COPY hello.c . # buildkit
240MB RUN /bin/sh -c apk add --no-cache build-base…
A later layer can only hide a file. The 240MB is still there, and the delete added 81.9kB of its own. Compare:
| Dockerfile | Image size |
|---|---|
install, compile, apk del in separate RUNs |
336MB |
install, compile, apk del in one RUN |
13.5MB |
multi-stage: compile in build, copy the binary out |
13.4MB |
alpine:3.22 on its own |
13.4MB |
The single RUN came within 0.1MB of multi-stage. The case for multi-stage builds is less about bytes than about never undoing an install: the final stage never held the compiler.
Never pass a secret through ARG or ENV
A registry token passed as a build argument:
FROM alpine:3.22
ARG API_TOKEN
RUN echo "authenticating with ${#API_TOKEN}-char token" && touch /done
The build printed WARN: SecretsUsedInArgOrEnv, finished anyway, and docker history --no-trunc gave the value back in full:
RUN |1 API_TOKEN=not-a-real-secret-123 /bin/sh -c echo "authenticating with ${#API_TOKEN}-char token" && touch /done # buildkit
docker image inspect did not show it, which makes it easy to miss, but the docker save tar contained the string, so a push ships it. As ENV it was worse: it appeared in Config.Env and was printed by echo "$API_TOKEN" inside a running container. Docker's build secrets guide is blunt about why: build arguments and environment variables "persist in the final image".
The replacement mounts the secret for one instruction:
FROM alpine:3.22
RUN --mount=type=secret,id=api_token \
echo "authenticating with $(wc -c < /run/secrets/api_token)-byte token" && touch /done
docker build --secret id=api_token,src=token.txt -t dfbp-secret-mount .
The step read all 21 bytes; the history line has no value, the docker save tar has zero occurrences, and /run/secrets does not exist at runtime.
Exec form is necessary, and not enough
The usual advice: shell form (CMD node server.js) wraps your process in /bin/sh, the shell swallows SIGTERM, and exec form fixes it. Three docker stop runs per variant:
| Image, CMD | PID 1 | Median stop | Exit |
|---|---|---|---|
python:3.13-slim, shell form |
/bin/sh -c python app.py |
3.15s | 137 |
python:3.13-slim, exec form |
python app.py |
3.17s | 137 |
node:22-alpine, shell form |
node server.js |
3.17s | 137 |
node:22-alpine, echo starting && node server.js |
node server.js |
3.21s | 137 |
node:22-alpine, exec form |
node server.js |
3.17s | 137 |
First surprise: on Alpine, BusyBox 1.37.0's sh -c replaced itself with the last command, even after &&, so no shell stayed at PID 1. Debian's dash 0.5.12 kept the shell, as the advice describes.
The bigger one: exec form changed nothing. Every variant was killed with 137. The Linux pid_namespaces manual explains it: a process in an ancestor namespace can signal a namespace's init process only if that process "has established a handler for that signal". docker stop sends from outside, so an app at PID 1 with no SIGTERM handler never receives it, and SIGKILL follows. Two things did work:
| Fix | Median stop | Exit |
|---|---|---|
Node exec form, process.on("SIGTERM", …) closes the server |
0.21s | 0 |
Node exec form, docker run --init |
0.12s | 143 |
Python exec form, docker run --init |
0.14s | 143 |
--init runs a tiny init as PID 1 that forwards the signal, and the app dies of the default action (143 is 128 + 15). A handler is better, because the application decides what shutdown means; mine closed the server and exited 0.
Note
The Docker reference says that with no container-level timeout "the Daemon determines the default, and is 10 seconds for Linux containers". On this Docker Desktop a plain
docker stopkilled after about 3.2s, whiledocker stop -t 10and--stop-timeout 10waited 10.16s and 10.18s. I could not find the setting responsible, so check your own daemon.
If something other than docker run will start the image, such as a scheduler (see Docker vs Kubernetes), put the fix in the application rather than in a flag.
Run as a non-root user
Neither official base image sets one: Config.User is empty for both node:22-alpine and python:3.13-slim, and id in each prints uid=0(root). The Node image already ships a node user at uid 1000, so the fix is one line — with a catch:
uid=1000(node) gid=1000(node) groups=1000(node),1000(node)
touch: /app/cache.json: Permission denied
WORKDIR and COPY created /app and its files as root before USER took effect. Handing over the directory fixes it:
FROM node:22-alpine
WORKDIR /app
RUN chown node:node /app
COPY --chown=node:node server.js .
USER node
CMD ["node", "server.js"]
The touch then exits 0. The non-root image was 143 bytes larger than the root one. The best-practices guide puts it simply: "If a service can run without privileges, use USER to change to a non-root user."
Pin the base image, and let the build check itself
On the day of these builds alpine:3 and alpine:latest resolved to the same digest, sha256:28bd5fe8…, and /etc/alpine-release inside them read 3.24.1. alpine:3.22 resolved to a different digest and read 3.22.5. A FROM alpine:3 written in the 3.22 era now builds on 3.24 without anyone touching it. Pin the minor version at least, or the digest (FROM alpine:3.22@sha256:14358309…) when builds must be reproducible.
The last practice replaces remembering the others. docker build --check runs BuildKit's build checks and stops there:
JSON arguments recommended for CMD to prevent unintended behavior related to OS signals
It exited 1 on the shell-form Dockerfile and on the ARG API_TOKEN one, and 0 with Check complete, no warnings found. on the reordered Node Dockerfile, which makes it a one-line job in a GitHub Actions pipeline. It passed, with no warnings, a context with no .dockerignore and a .env in it, the later-layer apk del Dockerfile, and exec form with no signal handler; those still need you.
Running several containers together is the next step: Docker Compose with Spring Boot.
[!TAKEAWAY] Order for the cache, ignore what you do not ship, delete in the layer that created it, mount secrets instead of passing them, drop root, and pin the base. Then test the one rule no linter checks: run
docker stopon your container and read the exit code. If it is 137, your application never saw SIGTERM, whatever form your CMD is in.
Frequently asked questions
- What is the most important Dockerfile best practice?
- Instruction order, because it costs nothing and pays on every build. Copy the dependency manifest and lockfile, install, and only then copy the source. In the measurement here a one-line source change rebuilt in a median 0.54s with the 54MB dependency layer cached, against 4.81s when COPY . . came first and every change reinstalled all 82 packages.
- Does deleting files in a later RUN make the image smaller?
- No. Every RUN adds a layer and a later layer can only hide files, not remove the bytes beneath it. Installing build-base in one RUN and removing it in another left a 336MB image whose history still shows the 240MB install layer. The same work in a single RUN gave 13.5MB, and a multi-stage build 13.4MB.
- Is it safe to pass a token with --build-arg?
- No. The value used in a RUN is recorded in the image history: docker history --no-trunc printed the fake token in full, and it was present in the tar produced by docker save, so a push would ship it. BuildKit warns with SecretsUsedInArgOrEnv. A RUN --mount=type=secret build left no trace in the history or the saved image.
- Does exec form CMD fix slow docker stop?
- Not on its own. Exec form removed the shell from PID 1 on Debian, but Node and Python running as PID 1 with no SIGTERM handler still ignored the signal and were killed with exit code 137. Adding a handler stopped the container in about 0.2s with exit 0, and docker run --init stopped it in 0.12 to 0.14s with exit 143.
- How do I check a Dockerfile against these rules automatically?
- Run docker build --check. It runs BuildKit's build checks, printed JSONArgsRecommended for a shell-form CMD and SecretsUsedInArgOrEnv for a token passed through ARG, and exited with status 1 on both. A clean Dockerfile printed "Check complete, no warnings found." and exited 0, so it works as a CI gate.
References
- Building best practicesDocker
- Build secretsDocker
- Build contextDocker
- Build checks: JSONArgsRecommendedDocker
- pid_namespaces(7)Linux man-pages
- docker container stop referenceDocker