Skip to main content

Dockerfile security best practices, from base image to healthcheck

A Dockerfile is a short file with a long reach. Twenty lines decide which operating system you ship, which user your process runs as, which credentials end up baked into a layer, and whether the image you build next month is the one you tested today. Most of the mistakes are invisible in a running container and obvious in the source, which makes the Dockerfile one of the cheapest places to fix them.

Here are the practices that matter most, each with a vulnerable and a fixed version.

Do not run as root

If no USER instruction appears, the container process runs as root inside the container. Namespaces and capabilities limit what that root can do on the host, but a container escape or a mounted volume turns it into real damage, and inside the container it can rewrite the application itself.

# Vulnerable: no USER, so the app runs as root
FROM node:22-bookworm-slim
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]
# Fixed: files owned by a non-root user, process runs as that user
FROM node:22-bookworm-slim
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]

Docker's own guidance is short: if a service can run without privileges, use USER to switch to a non-root user, and set an explicit UID and GID so they do not change between builds (Dockerfile best practices). Avoid sudo inside images. If the app needs to bind a low port, map the port at runtime instead.

Pin the base image

FROM python:latest or a bare FROM python means the image you build depends on the day you build it. Tags are mutable: a publisher can move a tag to a new image at any time, so two builds of the same commit can contain different packages.

# Vulnerable: whatever "latest" is today
FROM python:latest
# Better: a specific version and variant
FROM python:3.12-slim-bookworm

# Strongest: the same, pinned to a digest
FROM python:3.12-slim-bookworm@sha256:<digest-you-tested>

Digest pinning gives reproducible builds, with a cost: you no longer pick up base image fixes automatically. The answer is not to drop the pin but to automate the update, with a bot that opens a pull request when a new digest is published. Docker's documentation recommends exactly this trade.

Keep secrets out of layers and build args

Every instruction creates a layer, and layers are part of the image. A secret copied in and deleted in a later step is still in the earlier layer. Build arguments are not safe either: Docker's reference warns against passing credentials through ARG because they are visible through docker history (Dockerfile reference). ENV is worse, because the value is in the image configuration and in every running container's environment.

# Vulnerable: the token is recoverable from the image
ARG NPM_TOKEN
ENV NPM_TOKEN=${NPM_TOKEN}
COPY .npmrc .npmrc
RUN npm ci && rm .npmrc
# syntax=docker/dockerfile:1
# Fixed: a BuildKit secret mount, never written to a layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

The secret is mounted only for that RUN step and is not stored in the image (Build secrets). Pair it with a .dockerignore that excludes .env, .git, *.pem and local credential files, so COPY . . cannot sweep them in by accident.

Prefer COPY over ADD

ADD does more than copy. It extracts local tar archives automatically and it can fetch remote URLs. Both behaviours surprise people: an archive you meant to ship as a file gets unpacked, and a URL fetch pulls content you never reviewed into the build.

# Surprising: unpacks the archive, fetches an unverified URL
ADD vendor.tar.gz /opt/vendor/
ADD https://example.com/tool.tar.gz /opt/
# Clear: copy means copy; remote content is verified
COPY vendor.tar.gz /opt/vendor/
ADD --checksum=sha256:<expected-hash> https://example.com/tool.tar.gz /opt/

Use COPY by default. When you do fetch remote content, use ADD --checksum (or curl plus a checksum check in a RUN step) so a changed file breaks the build instead of shipping.

Build small, ship smaller

Every package in the final image is something that can carry a CVE and something an attacker can use after getting in. A compiler, a shell and a package manager are useful during the build and a liability at runtime.

# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/api ./cmd/api

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
USER nonroot
ENTRYPOINT ["/api"]

Multi-stage builds keep the toolchain in the first stage. Slim, distroless or scratch images shrink the attack surface and the vulnerability report at the same time.

Add a healthcheck

HEALTHCHECK is a reliability control more than a security one, but it belongs on the list because an unhealthy container that keeps receiving traffic hides problems. Defaults check every 30 seconds with three retries.

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD ["/api", "healthcheck"]

Use an exec form command your image actually contains. Distroless images have no curl. Kubernetes ignores HEALTHCHECK and uses its own liveness and readiness probes, so set those in the manifest too.

Scan the Dockerfile and the image

The Dockerfile and the built image are two different things to check. The Dockerfile tells you about intent: the user, the base tag, the secrets in ENV and ARG. The image tells you about content: the exact packages that ended up inside.

Vulkro checks both, offline. A normal vulkro scan reads Dockerfiles, Docker Compose files and Kubernetes manifests, and flags a base image on :latest or with no tag, a final stage that runs as root, and credentials in ENV or ARG. For the built image, vulkro container reads the installed packages and matches them against the local vulnerability data:

vulkro container alpine:3.19
docker save myapp:latest -o myapp.tar && vulkro container ./myapp.tar

It reads apk and dpkg databases, the SQLite rpm database used by RHEL 9 and later, Fedora, Rocky and Alma, plus Java jars, npm node_modules and Python package metadata. It never pulls from a registry, and the image contents stay on your machine. Exit codes are 0 for no findings, 1 for findings and 2 for an error, so it drops straight into a CI step. Details are on the container command page.

FAQ

Should a Docker container run as root?

No, unless it genuinely needs privileges. Add a USER instruction with a non-root user, give that user ownership of the files it needs, and map low ports at runtime instead of binding them as root.

Are Docker build args safe for secrets?

No. Build arguments are visible through docker history, and ENV values persist in the image. Use BuildKit secret mounts (RUN --mount=type=secret) with docker build --secret.

What is the difference between ADD and COPY in a Dockerfile?

COPY copies files from the build context. ADD also extracts local tar archives and can download remote URLs. Use COPY by default, and ADD --checksum only when you need a verified remote file.

Keep your image scans current offline with the vulnerability bundle, or start a 14-day trial at /start/. Related: Supply chain attacks are a code problem and Ranking dependency alerts by real risk.