Dockerfile Best Practices

By Vishvesh Patel · DevOps EngineerUpdated September 16, 2026

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.

PriorityPracticeWhat it prevents
1Exec-form CMDContainers that ignore SIGTERM and get killed
2Non-root USERA container escape becoming host root
3Secrets via BuildKit mountsCredentials permanently readable in the image
4Dependency layer orderingMulti-minute rebuilds on every commit
5Multi-stage buildsShipping 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
RuleWhat it catches
DL3002Last user is root
DL3008Unpinned apt-get install versions
DL3009apt lists not deleted
DL3015Missing --no-install-recommends
DL3025CMD/ENTRYPOINT in shell form
DL3059Consecutive RUN instructions
SC2086Unquoted 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.

Exercise

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
  • .dockerignore excludes node_modules, .git, .env*, keys
  • Dependency manifest copied and installed before source
  • Cleanup in the same RUN as the install
  • No secrets in ENV or ARG; BuildKit mounts instead
  • USER set, numeric if bound for Kubernetes
  • CMD and ENTRYPOINT in exec form
  • STOPSIGNAL set if the app wants something other than SIGTERM
  • Multi-stage build wherever there is a compile step
  • EXPOSE documents the port; app binds 0.0.0.0
  • OCI labels, at minimum image.source
  • hadolint clean
  • Scanner runs in CI with a non-zero exit on HIGH/CRITICAL

Key takeaways

  • Exec form first. Shell form means your app never sees SIGTERM and every deploy drops requests.
  • PID 1 has no default handlers: use --init or 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 USER for Kubernetes, plus readOnlyRootFilesystem and 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

Exercise

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.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Docker Playground

Run the commands in a simulated terminal, no install needed.

When it breaks

Related Resources