← Blog

Dockerfile Security Best Practices

Dockerfile security best practices: pin trusted base images, run as non-root, use multi-stage builds, keep secrets out, and scan every image in CI.

Bruno Baldo·Oct 5, 2026·Updated Sep 14, 2026·9 min read·Reviewed by Rainforest Technologies
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.

Frequently asked questions

What makes a Dockerfile insecure?

The usual culprits are a bloated or untrusted base image, running as root, secrets passed through ARG, ENV, or copied files (which persist in image layers), a broad COPY . . that sweeps in .git and .env, unpinned base images and dependencies, and skipping image scanning in CI. Each one either enlarges the attack surface or leaves sensitive data in the image. Following Dockerfile security best practices, minimal pinned bases, a non-root user, multi-stage builds, BuildKit secrets, and CI scanning, removes most of that risk.

Should I use :latest or pin image digests?

Pin digests. A tag like :latest is a moving pointer that can resolve to different content over time, which breaks reproducibility and lets upstream changes enter your build unreviewed. Pinning by digest (image@sha256:...) makes builds byte-for-byte reproducible and turns any base-image change into a deliberate, reviewable commit. Use a readable version tag for humans if you like, but anchor it to a digest.

Why should containers not run as root?

Because root inside the container is one bug away from root on the host. If an attacker compromises the application, running as root gives them the leverage to exploit a kernel flaw or misconfiguration and escape the container. Most workloads never need root, so create a dedicated unprivileged USER, use COPY --chown to give it the files it needs, and drop Linux capabilities to enforce least privilege.

How do I keep secrets out of a Docker image?

Never pass secrets through ARG, ENV, or copied credential files, they are written into image layers and remain readable in the image history even if you delete them later. For build-time secrets, use BuildKit's --mount=type=secret, which exposes the value only during a single RUN and never writes it to a layer. For runtime secrets, inject them through your orchestrator or a secrets manager rather than the image. Add a .dockerignore so credential files never enter the build context, and scan images in CI to catch leaks.

What is a multi-stage build?

A multi-stage build uses more than one FROM in a single Dockerfile. An early builder stage carries the full toolchain, compilers, package managers, dev dependencies, and does the heavy work, then a final stage copies only the finished artifact into a clean, minimal runtime image. The result ships without build tools or intermediate files, so the attack surface is far smaller and build-time secrets never make it into production.

Bruno Baldo

Written by

Bruno Baldo

CMO

Um pouco de marketing e um pouco de curiosidade e temos a receita pra criar um apaixonado por cyber!

Keep reading