A Dockerfile is a small file with an outsized blast radius. Those few dozen lines decide what code, what operating system packages, and what privileges end up running in production, often across thousands of container instances. That is why Dockerfile security deserves the same care you give application code: a careless base image, a stray COPY . ., or a secret passed through ARG can quietly hand an attacker a foothold that no amount of runtime tooling will fully undo. Getting the Dockerfile right is the cheapest, earliest place to harden a container.
The encouraging part is that a secure Dockerfile is mostly a matter of well-understood habits rather than exotic tooling. This deep dive walks through the practices that matter most for container hardening: choosing minimal and trusted base images, running as an unprivileged user, using multi-stage builds to shrink the attack surface, keeping secrets out of layers, minimizing what you install, copying only what you need, pinning versions, adding a healthcheck, tightening the runtime, and scanning every image before it ships. It is part of our broader Docker container security guide, which ties these image-level Dockerfile best practices together with registry and runtime concerns.
Start from a minimal, trusted base image
Every line of your Dockerfile builds on the base image, so the base image is your largest inherited risk. A full ubuntu or node image ships a shell, a package manager, and dozens of libraries you will never call but an attacker might. Prefer a minimal variant, a -slim tag or, better still, a distroless image that contains only your language runtime and its dependencies, with no shell and no package manager to abuse.
Just as important, know exactly what you are pulling. Use official or verified-publisher images, and pin them by digest rather than a moving tag. A tag like :latest or even :20 can point at different content tomorrow than it does today, which quietly breaks reproducibility and lets an upstream change slip in unreviewed.
# Fragile: the tag can move under you
FROM node:latest
# Better: a slim, digest-pinned base you can reproduce and audit
FROM node:20.11.1-slim@sha256:2b3f1c...
Pinning by digest means the build is byte-for-byte reproducible and any base-image change becomes a deliberate, reviewable commit rather than a silent surprise.
Run as a non-root user
By default, processes inside a container run as root. If an attacker escapes the application, root in the container plus a kernel or misconfiguration bug can become root on the host. Most workloads never need it. Create an unprivileged user and switch to it before the container's main process starts.
# Create a dedicated, unprivileged user and own the app directory
RUN groupadd --system app && useradd --system --gid app --no-create-home app
WORKDIR /app
COPY --chown=app:app . .
USER app
USER app ensures the entrypoint runs unprivileged. Pair it with dropping Linux capabilities you do not need, so even the permissions root would normally carry are stripped away. Capabilities are enforced at runtime, but the Dockerfile is where you signal and document the intent, and where a non-root USER makes the least-privilege default stick.
Two details are easy to miss. First, put the USER instruction after any step that genuinely needs elevated rights, such as installing packages, so those steps still work while the running process stays unprivileged. Second, make sure the unprivileged user actually owns the files and directories it must write to at runtime; COPY --chown handles the files you copy, and an explicit chown covers directories the application creates data in. If you skip that, the container starts as the right user but fails the moment it tries to write, which tends to push teams back toward root out of frustration.
Use multi-stage builds
Building software needs compilers, headers, dev packages, and sometimes credentials. None of that belongs in the image you deploy. A multi-stage build lets you do the heavy work in one stage and copy only the finished artifact into a clean, minimal final stage.
# Stage 1: build with the full toolchain
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/app ./cmd/app
# Stage 2: ship only the binary on a distroless runtime
FROM gcr.io/distroless/static-debian12@sha256:...
COPY --from=build /bin/app /bin/app
USER nonroot:nonroot
ENTRYPOINT ["/bin/app"]
The runtime image has no compiler, no shell, and no build cache, so the attack surface shrinks dramatically. Multi-stage builds are also the cleanest way to make sure build-time tools and intermediate files never leak into what runs in production.
Keep secrets out of the image entirely
This is the mistake that hurts most, because it is invisible in the running container yet permanent in the image. Secrets passed through ARG or ENV, or copied in as files, are written into image layers, and those layers live forever in the image history. Deleting a file in a later layer does not remove it, anyone who can pull the image can run docker history or unpack the layers and read it.
# Wrong: the token is now baked into a layer for good
ARG NPM_TOKEN
RUN npm ci
Instead, use BuildKit's secret mounts. The secret is available only during that one RUN instruction and is never written to a layer.
# syntax=docker/dockerfile:1
FROM node:20.11.1-slim@sha256:...
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
You then supply it at build time with docker build --secret id=npm_token,src=./npm_token.txt. The credential does the job and leaves no trace in the final image. For runtime secrets, inject them through your orchestrator or a secrets manager rather than the Dockerfile, so nothing sensitive is ever part of the artifact.
Minimize layers, packages, and caches
Every package you install is more code that can carry a vulnerability, and every leftover cache is dead weight and extra attack surface. Install only what you need, avoid recommended-but-unneeded extras, and clean up in the same RUN so the cleanup lands in the same layer.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
--no-install-recommends keeps apt from pulling in optional packages, and removing the package lists in the same layer stops them from bloating the image. Fewer packages means fewer CVEs to triage and a smaller surface to defend.
Copy only what you need, and use a .dockerignore
COPY . . is convenient and dangerous. It happily sweeps your entire working directory into the image, including .git history, local .env files, credentials, and CI artifacts. Copy specific paths, and back that up with a .dockerignore so sensitive files never enter the build context in the first place.
# Copy dependency manifests first, then only the source you need
COPY package*.json ./
RUN npm ci
COPY src ./src
# .dockerignore
.git
.env
node_modules
*.log
**/secrets/*
A good .dockerignore also speeds up builds by keeping the context small, so it pays for itself twice.
Pin dependency and package versions
Reproducibility is a security property. If a build can pull a different version of a dependency or OS package on each run, you cannot reason about what is actually in the image, and a compromised upstream release can slide in unnoticed. Pin application dependencies through a committed lockfile and install from it, and pin OS packages to known versions where your base distribution supports it.
# Install from the committed lockfile, not "whatever is latest"
COPY package.json package-lock.json ./
RUN npm ci
npm ci (and its equivalents in other ecosystems) installs exactly what the lockfile specifies and fails if the two disagree, which is precisely the behavior you want in a build pipeline.
Add a healthcheck and tighten the runtime
A HEALTHCHECK gives your orchestrator a reliable signal for when a container is genuinely serving rather than merely started, which helps it replace compromised or wedged instances promptly.
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD ["/bin/app", "healthcheck"]
The Dockerfile also sets you up for a locked-down runtime. Design the image so it can run with a read-only root filesystem, writing only to explicitly mounted volumes or tmpfs, and so it needs no setuid or setgid binaries. Setuid and setgid binaries let a program run with the file owner's privileges regardless of who invokes it, which is exactly the kind of latent escalation path you do not want sitting in a container. You can find and strip those bits during the build, then enforce the read-only root filesystem and dropped capabilities when you run the container.
# Remove setuid/setgid bits from binaries you do not need them on
RUN find / -xdev -perm /6000 -type f -exec chmod a-s {} + || true
An image built to run read-only and unprivileged, with no setuid escalation paths, gives an attacker far less room to persist, tamper with the filesystem, or escalate. The Dockerfile cannot flip on the read-only flag itself, that is a runtime setting, but building the image to tolerate it is what makes turning it on painless later.
Verify, scan, and label in CI
The final safeguard is automation. A Dockerfile that looked fine last quarter can be riddled with newly disclosed CVEs today, because vulnerabilities are found in dependencies you already shipped. Scan every image on every build in CI, fail the pipeline on high-severity findings, and add provenance labels so you can trace any image back to its source.
LABEL org.opencontainers.image.source="https://example.com/org/repo" \
org.opencontainers.image.revision="$GIT_SHA"
Automated scanning is what turns all of the practices above from good intentions into an enforced standard. It catches the vulnerable base image nobody noticed, the secret that slipped into a layer, and the package that picked up a critical CVE since the last release. Insecure defaults and injection-style flaws remain a running theme in the OWASP Top 10 (2025), and container images are one of the places those weaknesses quietly accumulate.
How Rainforest helps
Rainforest brings your container images into the same security workflow as the rest of your code. Our container security scanning analyzes images for vulnerable and outdated base images, exposed secrets baked into layers, risky configuration, and insecure defaults, then surfaces findings with the severity and context your team needs to act. Because it runs as part of the broader application security testing platform, the same scan fires on every build, so a risky image is flagged before it reaches a registry, not after an incident.
The payoff is images you can actually trust: minimal and pinned bases, non-root by default, no secrets in the layers, and a CI gate that keeps every build honest. Want to see it against your own images? Book a demo and we will walk through it with your stack.