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 |
The most commonly shipped Dockerfile bug, because everything looks fine until a deploy.
CMD node server.js # shell form
CMD ["node", "server.js"] # exec formShell 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 killsExec form makes your process PID 1 and it receives the signal directly.
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 myappRUN 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 SIGTERMFROM 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.
ENV DB_PASSWORD=supersecret # in docker inspect, forever
ARG NPM_TOKEN # in docker history, foreverDeleting 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 tokenUse 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 .npmrcdocker build --secret id=npm_token,env=NPM_TOKEN -t myapp .Once a secret has shipped in an image, rotating it is the only remedy.
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=devAnd 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.
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.
FROM node:latest # moves without warning
FROM node:22.11.0-alpine3.20 # a specific release
FROM node:22-alpine@sha256:bf77dc26⦠# immutablePin 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.
docker scout cves myapp:1.0
trivy image myapp:1.0trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:1.0That 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 .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: warningHEALTHCHECK --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.
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.
<MCQExercise exercise={{ id: 'lint-dockerfile-q1', title: 'Lint a Dockerfile', description: '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?', options: [ { id: 'a', text: 'docker run --rm -i hadolint/hadolint < Dockerfile', }, { id: 'b', text: 'docker run --rm hadolint/hadolint Dockerfile', }, { id: 'c', text: 'docker run --rm -i hadolint/hadolint -f Dockerfile', }, { id: 'd', text: 'docker run -i hadolint/hadolint < Dockerfile', }, ], correctId: 'a', explanation: 'The hadolint image reads the Dockerfile content from stdin by default. "-i" keeps stdin open so the container can receive piped input, and shell redirection "< Dockerfile" feeds the file's contents into the container. "--rm" automatically removes the container once hadolint finishes, avoiding leftover stopped containers.', wrongExplanations: { b: 'Passing "Dockerfile" as an argument refers to a path inside the container's filesystem, not on the host. Since the host's Dockerfile was never mounted or copied into the container, hadolint would not find a file at that path and would fail or report an error.', c: 'The hadolint container does not have access to a file named "Dockerfile" on the host filesystem unless it is mounted as a volume. The "-f" flag (and similar path-based arguments) refer to paths inside the container, so this would fail without a volume mount.', d: 'Without "--rm", the container used for this one-off lint run is not automatically removed after it exits, leaving a stopped container behind. While the lint itself might still work, this does not match the goal of running it without leaving artifacts behind.', }, }} />
.dockerignore excludes node_modules, .git, .env*, keysRUN as the installENV or ARG; BuildKit mounts insteadUSER set, numeric if bound for KubernetesCMD and ENTRYPOINT in exec formSTOPSIGNAL set if the app wants something other than SIGTERMEXPOSE documents the port; app binds 0.0.0.0image.sourcehadolint cleanSIGTERM and every deploy drops requests.--init or tini when the process cannot handle signals itself.USER for Kubernetes, plus readOnlyRootFilesystem and dropped capabilities.--exit-code 1.HEALTHCHECK. Write probes.<MCQExercise exercise={{ id: 'docker-bp-sigterm', title: 'A deploy that always drops requests', description: '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?', options: [ { id: 'a', text: 'Shell-form CMD makes /bin/sh PID 1, and it does not forward SIGTERM to the application' }, { id: 'b', text: 'The termination grace period is too short and needs raising' }, { id: 'c', text: 'SIGTERM cannot reach a containerised process without --cap-add' }, { id: 'd', text: 'The application needs a HEALTHCHECK before Docker will signal it' }, ], correctId: 'a', explanation: 'CMD node server.js is shell form, which Docker runs as /bin/sh -c "node server.js". The shell becomes PID 1 and the Node process is its child. SIGTERM is delivered to PID 1, the shell, which has no handler and does not pass it on, so the application never learns it should drain. After the grace period, SIGKILL takes down the whole container mid-request. Switching to the exec form CMD ["node", "server.js"] makes Node itself PID 1 and the existing handler starts working.', wrongExplanations: { b: 'A longer grace period just makes the same failure slower. The signal is not reaching the process at all.', c: 'Signal delivery needs no special capability. The problem is which process is receiving it.', d: 'HEALTHCHECK reports status and plays no part in how termination signals are delivered.', }, }} />
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.
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.
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.
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.
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.
Copy production-ready Dockerfile examples for Node.js, Python FastAPI, Go, React with Nginx, Spring Boot and Django, and see the pattern they all share.
Learn why container data disappears and how Docker volumes fix it. Compare the three mount types, handle permission problems, and back up or clean up volumes.
Create Docker named volumes with docker volume create, -v and --mount. See how data survives container removal and how to share a volume.