Dockerfile Best Practices
A prioritised checklist for production Dockerfiles: the PID 1 signal problem, secrets that survive deletion, read-only filesystems, and hadolint in CI.
A Dockerfile that builds is not the same as one you would put in production. These are the practices that matter, ordered by what they actually buy you, because a checklist where every item looks equally important is a checklist nobody finishes.
The top five below cover most of the real-world damage. The rest are worth doing and none of them will page you at 3am.
| Priority | Practice | What it prevents |
|---|---|---|
| 1 | Exec-form CMD | Containers that ignore SIGTERM and get killed |
| 2 | Non-root USER | A container escape becoming host root |
| 3 | Secrets via BuildKit mounts | Credentials permanently readable in the image |
| 4 | Dependency layer ordering | Multi-minute rebuilds on every commit |
| 5 | Multi-stage builds | Shipping compilers and dev dependencies |
1. Exec form, or your app never gets SIGTERM
The most commonly shipped Dockerfile bug, because everything looks fine until a deploy.
CMD node server.js # shell form
CMD ["node", "server.js"] # exec form
Shell form becomes /bin/sh -c "node server.js". The shell is PID 1; your application is its child. When Docker or Kubernetes sends SIGTERM, sh receives it and does not forward it. Your app keeps serving until the grace period expires and SIGKILL arrives. Dropping in-flight requests on every single deploy.
docker stop myapp # takes the full 10s, then kills
Exec form makes your process PID 1 and it receives the signal directly.
PID 1 has no default signal handlers
A second, subtler catch: the kernel gives PID 1 no default action for SIGTERM. A process that never installs a handler ignores it even in exec form. Most frameworks handle this; plenty of scripts do not.
If your application cannot handle signals, or spawns children that leave zombies, give it a real init:
docker run --init myapp
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
Some servers want a different signal entirely:
STOPSIGNAL SIGQUIT # nginx drains connections on SIGQUIT, not SIGTERM
2. Never run as root
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER 1000
CMD ["node", "server.js"]
Root in a container is not root on the host in the way it once was, but it removes a layer of defence for free, and combined with a mounted host path or a kernel vulnerability it is the difference between an incident and a catastrophe.
Use a numeric UID if the image will run on Kubernetes, runAsNonRoot: true cannot verify a named user and the Pod fails to start.
Then take the next step and make the filesystem read-only:
docker run --read-only --tmpfs /tmp myapp
# Kubernetes
securityContext:
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
An attacker who cannot write to the filesystem cannot drop a payload on it.
3. Secrets never survive deletion
ENV DB_PASSWORD=supersecret # in docker inspect, forever
ARG NPM_TOKEN # in docker history, forever
Deleting the file afterwards changes nothing. Layers are immutable, and the deletion only adds a whiteout on top.
docker history myapp:1.0 --no-trunc | grep -i token
Use a secret mount, which exists for one instruction and is written to no layer:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > .npmrc \
&& npm ci \
&& rm .npmrc
docker build --secret id=npm_token,env=NPM_TOKEN -t myapp .
Once a secret has shipped in an image, rotating it is the only remedy.
4. Order layers by change frequency
FROM node:22-alpine # changes on version bumps
WORKDIR /app
RUN apk add --no-cache curl # changes rarely
COPY package*.json ./ # changes when dependencies do
RUN npm ci --omit=dev # expensive. Keep it cached
COPY . . # changes every commit
CMD ["node", "server.js"]
Docker reuses cached layers up to the first changed instruction, then rebuilds everything below. COPY . . above the install means every one-character edit reinstalls every dependency.
Add cache mounts for the step itself, which keeps the package cache on the builder without putting a byte in the image:
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
And write .dockerignore before your first COPY . .:
node_modules/
.git/
.env
.env.*
*.pem
dist/
coverage/
Three benefits at once: a smaller build context, no cache invalidation from irrelevant files, and local secrets that never reach a layer.
5. Multi-stage builds
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER 1000
CMD ["node", "dist/server.js"]
Compilers, dev dependencies and source never reach what ships. For Go and Rust the effect is order-of-magnitude. A few hundred megabytes of build environment becomes a binary in a scratch or distroless/static image.
It is a security practice as much as a size one. A compiler in a production image is a tool an attacker gets for free.
6. Pin the base image
FROM node:latest # moves without warning
FROM node:22.11.0-alpine3.20 # a specific release
FROM node:22-alpine@sha256:bf77dc26… # immutable
Pin a digest and let Renovate or Dependabot bump it, so updates arrive as reviewable pull requests. Where you keep a tag, rebuild with --pull so a stale cached base does not quietly carry old CVEs into a "fresh" build.
7. Scan, and fail the build on it
docker scout cves myapp:1.0
trivy image myapp:1.0
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:1.0
That exit code is the part that matters. A scan whose output nobody reads is theatre; a scan that fails the pipeline is a control. Start with CRITICAL only if the backlog is large, then tighten.
Generate provenance and an SBOM while you are there. Increasingly expected, and one flag each:
docker buildx build --sbom=true --provenance=true -t myapp:1.0 --push .
8. Lint it
Hadolint catches most of this list mechanically:
docker run --rm -i hadolint/hadolint < Dockerfile
| Rule | What it catches |
|---|---|
DL3002 | Last user is root |
DL3008 | Unpinned apt-get install versions |
DL3009 | apt lists not deleted |
DL3015 | Missing --no-install-recommends |
DL3025 | CMD/ENTRYPOINT in shell form |
DL3059 | Consecutive RUN instructions |
SC2086 | Unquoted variable, word-splitting risk |
In CI:
- name: Lint Dockerfile
uses: hadolint/hadolint-action@v3
with:
dockerfile: Dockerfile
failure-threshold: warning
9. Health checks, with one caveat
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
--start-period is the option that matters. Too little and a slow-starting application is marked unhealthy while it is still booting.
Kubernetes ignores HEALTHCHECK entirely. It reads only livenessProbe, readinessProbe and startupProbe from the Pod spec. The instruction is genuinely useful for docker run, Swarm, and Compose's depends_on: condition: service_healthy, just do not expect it to do anything on a cluster.
10. Label for provenance
LABEL org.opencontainers.image.source="https://github.com/org/repo" \
org.opencontainers.image.revision="$GIT_SHA" \
org.opencontainers.image.version="2.1.0" \
org.opencontainers.image.licenses="MIT"
image.source is the one that earns its keep: it is what lets a registry link a published image back to the repository that built it, and what lets an on-call engineer find the code from a running container at 3am.
Lint a Dockerfile
You want to run hadolint against the Dockerfile in the current directory using the official hadolint Docker image, without leaving behind a stopped container afterward. Which command is correct?
The pre-merge checklist
- Base image pinned to a version tag, ideally a digest
-
.dockerignoreexcludesnode_modules,.git,.env*, keys - Dependency manifest copied and installed before source
- Cleanup in the same
RUNas the install - No secrets in
ENVorARG; BuildKit mounts instead -
USERset, numeric if bound for Kubernetes -
CMDandENTRYPOINTin exec form -
STOPSIGNALset if the app wants something other thanSIGTERM - Multi-stage build wherever there is a compile step
-
EXPOSEdocuments the port; app binds0.0.0.0 - OCI labels, at minimum
image.source -
hadolintclean - Scanner runs in CI with a non-zero exit on HIGH/CRITICAL
Key takeaways
- Exec form first. Shell form means your app never sees
SIGTERMand every deploy drops requests. - PID 1 has no default handlers: use
--initor tini when the process cannot handle signals itself. - A deleted secret is still a leaked secret. Only BuildKit mounts keep it out of the layers.
- Numeric
USERfor Kubernetes, plusreadOnlyRootFilesystemand dropped capabilities. - Manifest before source is the cheapest large build-speed win there is.
- Multi-stage builds are a security control, not only a size optimisation.
- A scan that cannot fail the build is decoration. Set
--exit-code 1. - Kubernetes ignores
HEALTHCHECK. Write probes.
Check your understanding
A deploy that always drops requests
Every rolling deploy drops in-flight requests. The container takes the full termination grace period and is then killed. The Dockerfile ends with CMD node server.js and the app does handle SIGTERM. What is wrong?
Frequently Asked Questions
Why doesn't my container stop gracefully on docker stop?
Almost certainly shell-form CMD. Written as CMD node server.js, Docker runs /bin/sh -c "node server.js", making the shell PID 1 and your application its child, and sh receives SIGTERM without forwarding it. After the grace period Docker sends SIGKILL and everything in flight is lost. Use exec form: CMD ["node", "server.js"]. If the process genuinely cannot handle signals, run with --init or add tini as the entrypoint.
Is it really a problem to run a container as root?
It is a defence you are giving up for one line. Container isolation is good, so root inside a container is not automatically root on the host, but combined with a bind-mounted host path, a privileged flag, or a kernel vulnerability, the gap closes fast. Add USER 1000, and on Kubernetes pair it with runAsNonRoot: true, readOnlyRootFilesystem: true and dropped capabilities. Use a numeric UID, because runAsNonRoot cannot verify a named user and the Pod will refuse to start.
How do I pass a secret to a Docker build safely?
With a BuildKit secret mount. RUN --mount=type=secret,id=npm_token … makes the value readable at /run/secrets/npm_token for that one instruction and writes it to no layer, with docker build --secret id=npm_token,env=NPM_TOKEN supplying it. Do not use ARG or ENV: both are recorded in the image metadata and docker history --no-trunc prints them back, even when the file you wrote was deleted in the same RUN. Once a secret has shipped in an image, rotation is the only fix.
What is the fastest way to make my Docker image smaller?
In order: use a multi-stage build so compilers and dev dependencies never ship, switch to a -slim or -alpine base variant (but not Alpine for Python), and make sure every cleanup happens in the same RUN as the thing it cleans up. Run docker history <image> --no-trunc first. It names the instruction responsible for each layer's size, which usually makes the answer obvious and saves you optimising something that was never the problem.
Which Dockerfile linter should I use, and what should it fail on?
Hadolint is the standard, and it runs as a container so there is nothing to install: docker run --rm -i hadolint/hadolint < Dockerfile. In CI, set failure-threshold: warning. Its defaults are sensible and most of its rules map to genuine production problems, such as DL3002 for a root final user and DL3025 for shell-form CMD. Pair it with an image scanner such as Trivy or Docker Scout, and give that a non-zero exit code on HIGH and CRITICAL so results block a merge rather than scroll past in a log.
Do I need a HEALTHCHECK in my Dockerfile?
Only if something reads it. docker run, Docker Swarm and Compose's depends_on: condition: service_healthy all do, and it is worth adding there. Kubernetes does not. It uses livenessProbe, readinessProbe and startupProbe from the Pod spec and ignores the image's healthcheck completely, so on a cluster the instruction buys you nothing and the probes are what you need to get right. Wherever you do use it, set --start-period generously or slow-starting applications are marked unhealthy while still booting.