PRACTITIONER GUIDE
Practitioner Guide12 min read

Dockerfile Security Hardening: Secure Base Images, Non-Root Execution, and CI/CD Scanning

~5 MB
typical size of an Alpine Linux base image, compared to 200-400 MB for a full Ubuntu or Debian base, directly reducing CVE exposure
UID 65532
nonroot user UID used by Google Distroless images, enabling non-root execution without writing a custom USER directive
--cap-drop=ALL
Docker runtime flag that removes all default Linux capabilities; add back only what your application actually requires
exit-code: 1
Trivy CI flag that fails the pipeline build when CRITICAL or HIGH severity CVEs are found in the image

SponsoredRetool

Retool's new app builder is where AI-generated code ships safely

Building apps with AI is easy. Getting them to production safely is another story.

Start building for free today

Most container image vulnerabilities are build-time decisions. The choice of base image determines 60-80% of the CVE count reported by image scanners — a full Ubuntu base image pulls in hundreds of packages your application never uses, each a potential vulnerability. Running as root, including build tools in the runtime image, and copying secrets are decisions made in the Dockerfile that have runtime security consequences.

The Dockerfile is the right place to fix these issues — hardening at build time is less expensive than runtime policy enforcement and cannot be bypassed by a misconfigured admission controller. This guide covers the specific Dockerfile instructions and build patterns that reduce your container attack surface without requiring changes to your application code.

Base image selection: the highest-leverage security decision

Your base image choice determines the majority of your image's CVE count before you write a single application-specific instruction. Switching from a full OS base (ubuntu:22.04, debian:bookworm) to a minimal alternative like Alpine or a distroless image routinely drops the scanner-reported CVE count by 80-95 percent, without changing your application code at all. The two tasks in this section are scanning your current images to quantify the CVE reduction available by re-basing, and pinning the replacement base image to an immutable digest rather than a mutable tag so your builds remain reproducible and tamper-evident.

Audit your current base images and CVE count

Before switching base images: run your current images through Trivy or Grype and document the CVE count. trivy image your-current-image:latest --severity HIGH,CRITICAL --format json | jq '.Results[].Vulnerabilities | length'. Then build the same image with Alpine or a distroless base and scan again. The reduction in CVE count is typically dramatic: an image built FROM ubuntu:22.04 with 200 CVEs drops to under 20 when rebuilt FROM python:3.12-slim and may drop to near zero when rebuilt FROM gcr.io/distroless/python3. Use this data to prioritize which images to re-base first — images with the most CVEs and the most internet exposure have the highest remediation value.

Pin base image versions with digest, not just tag

FROM python:3.12 uses the latest image tagged 3.12, which changes when the maintainer updates it. FROM python:3.12@sha256:abc123... uses a specific immutable image digest. Pinning by digest ensures: reproducible builds (the same image is used in every environment), protection against tag mutable attacks (a compromised Docker Hub account cannot silently substitute a malicious image under the same tag). To find the digest: docker pull python:3.12 && docker inspect python:3.12 --format='{{.RepoDigests}}'. Use a Dependabot or Renovate configuration to receive automated PRs when the pinned digest is updated to a newer version that fixes CVEs.

Secure Dockerfile patterns

A handful of Dockerfile patterns account for the majority of build-time security debt seen in cloud-native assessments: single-stage builds that bundle compilers and build tools into the runtime image, missing USER directives that default to root, and absent .dockerignore files that allow credential files to enter the build context. The items below cover the minimum viable secure Dockerfile structure for compiled and interpreted languages, and the .dockerignore configuration that prevents secrets from being accidentally included in any image layer. Establish these patterns as standards enforced during code review before any Dockerfile is merged.

The minimum viable secure Dockerfile structure

Stage 1 (build): FROM golang:1.22 AS builder; WORKDIR /build; COPY go.mod go.sum ./; RUN go mod download; COPY . .; RUN CGO_ENABLED=0 go build -o /app ./cmd/server. Stage 2 (runtime): FROM gcr.io/distroless/static:nonroot; COPY --from=builder /app /app; USER nonroot:nonroot; ENTRYPOINT ["/app"]. This pattern: uses distroless with the nonroot user (UID 65532 by default), copies only the compiled binary into the runtime image, runs with no shell, no package manager, and no write access to the root filesystem. The :nonroot tag means the image is pre-configured with a nonroot user — combine with securityContext.readOnlyRootFilesystem: true in Kubernetes for maximum hardening.

Use .dockerignore to protect secrets and reduce build context

A .dockerignore file at the root of your build context works like .gitignore — files matching the patterns are excluded from the build context and cannot be accidentally copied into the image. Required entries: .env, .env.*, *.pem, *.key, id_rsa, credentials.json, .aws, .ssh, secrets/, .git (git history may contain previously committed secrets). Optional but valuable: node_modules/, .cache/, dist/, build/ (reduces build context size, speeds builds). Verify your .dockerignore is working: docker image build --no-cache -t test . -- dry-run shows what would be sent to the daemon. An unexpectedly large build context (hundreds of MB) indicates .dockerignore is missing critical exclusions.

Free daily briefing

Briefings like this, every morning before 9am.

Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.

The bottom line

Dockerfile security is build-time security: the decisions you make in the Dockerfile determine your container's runtime attack surface more than any runtime policy you apply afterward. The four highest-impact changes in order of implementation difficulty: switch to a minimal base image (distroless or Alpine), add a non-root USER before ENTRYPOINT, implement multi-stage builds to exclude build tools from the runtime image, and add CI/CD scanning with trivy or grype to gate on critical CVEs before pushing to your registry. Each change is a single Dockerfile modification. After implementing all four: add --read-only to your container runtime configuration and drop all Linux capabilities not required by your application. The result is a container that is structurally difficult for an attacker to abuse even after gaining code execution.

Frequently asked questions

What base image should I use for a secure Docker container?

Choose the smallest base image that includes everything your application needs at runtime, and nothing more. Options in order of attack surface from smallest to largest: (1) Distroless (google/distroless/static, google/distroless/java, chainguard equivalents): no shell, no package manager, no OS utilities — the smallest possible attack surface. Best for compiled languages (Go, Java, Rust) where the binary has no OS dependencies. (2) Alpine Linux (alpine:3.x): small (5MB), has a shell and package manager but minimal footprint. Good for applications that need a package manager or shell access at runtime. (3) Slim variants (python:3.12-slim, node:20-slim, debian:bookworm-slim): stripped-down versions of full OS images, larger than Alpine but better compatibility for complex dependency trees. Avoid: full OS images (ubuntu:22.04, debian:bookworm) as base images — hundreds of MB of unnecessary packages add CVEs with no runtime benefit.

How do I run a Docker container as a non-root user?

Add a USER instruction to your Dockerfile before the CMD or ENTRYPOINT. First create a dedicated user: RUN groupadd -r appuser && useradd -r -g appuser appuser. Then set ownership of application files: RUN chown -R appuser:appuser /app. Then switch: USER appuser. Verify: docker run --rm your-image whoami should return 'appuser', not 'root'. For applications that need to bind to ports below 1024 (privileged ports): configure the application to bind to a port above 1024 (8080 instead of 80) and handle port mapping at the container runtime or load balancer level rather than running the container as root. For existing images you cannot modify: set the user at runtime with docker run -u 1000:1000 your-image or in your Kubernetes pod spec with securityContext.runAsUser: 1000.

How do multi-stage Docker builds improve security?

Multi-stage builds use multiple FROM instructions in a single Dockerfile, where each stage produces intermediate artifacts. Only the final stage becomes the runtime image. Example for a Go application: Stage 1 (builder): FROM golang:1.22 AS builder — copies source code, runs go build, produces a compiled binary. Stage 2 (runtime): FROM gcr.io/distroless/static AS runtime — copies only the compiled binary from the builder stage (COPY --from=builder /app/binary /app/binary). The runtime image contains only the binary and the distroless base, not the Go toolchain, source code, or any build dependencies. Security benefit: the runtime image has drastically fewer packages and attack surface. Size benefit: a Go application that was 800MB (including the golang base image) becomes 10-20MB (compiled binary plus distroless base).

How do I prevent secrets from being baked into Docker image layers?

Secrets must never enter the Docker build context or be copied into any image layer. Common mistake: COPY .env /app/.env in a Dockerfile — even if you delete it later with RUN rm /app/.env, the file exists in the layer created by COPY and can be extracted from the image. Safe approaches: (1) Build secrets with BuildKit (Docker BuildKit's --secret flag): RUN --mount=type=secret,id=mysecret,target=/run/secrets/mysecret command-that-needs-secret. The secret is available only during the RUN step and does not persist in the image layer. (2) Environment variables at runtime: pass secrets via docker run -e SECRET_VALUE=... or Kubernetes secrets mounted as environment variables — never hardcoded in the Dockerfile. (3) .dockerignore: add .env, *.key, credentials.json, and any other secrets file to .dockerignore to prevent them from entering the build context at all.

How do I integrate container image scanning into my CI/CD pipeline?

Integrate scanning at build time so vulnerabilities block deployment before they reach a registry. Tools: Trivy (open source, fast, broad ecosystem support), Grype (open source, good accuracy), Snyk Container (commercial), Docker Scout (Docker Desktop integrated). GitHub Actions example with Trivy: add a step after docker build that runs aquasecurity/trivy-action@master with image-ref pointing to the newly built image, format: sarif, exit-code: '1', severity: CRITICAL,HIGH. The exit-code: '1' causes the pipeline to fail if any critical or high severity CVEs are found. For a practical rollout: start with exit on CRITICAL only, baseline your current image, add HIGH severity failures after the baseline is clean. Require image scanning as a mandatory CI check before any image is pushed to your registry.

What Linux capabilities should I drop from Docker containers?

By default, Docker grants containers a set of Linux capabilities that are broader than most applications need. Drop all capabilities and add back only what is required: in docker run, use --cap-drop=ALL and add back only needed capabilities (--cap-add=NET_BIND_SERVICE if the process needs to bind ports below 1024). In Kubernetes pod spec: securityContext.capabilities.drop: [ALL], securityContext.capabilities.add: [NET_BIND_SERVICE]. Most web applications, APIs, and data processing containers need zero capabilities after dropping all defaults — test your application in a no-capability environment and add back only what breaks. Critical capabilities to never add: SYS_ADMIN (near-equivalent to root), NET_ADMIN (modify network configuration), SYS_PTRACE (attach to any process — frequently used in container escapes).

What is a read-only root filesystem and when should I use it for containers?

A read-only root filesystem prevents any process inside the container from modifying the container's filesystem, which eliminates a large class of post-exploitation techniques that require writing files (dropping payloads, modifying configuration, creating backdoors). Enable it: docker run --read-only your-image, or in Kubernetes: securityContext.readOnlyRootFilesystem: true. Applications that need to write temporary files: mount specific writable directories as tmpfs (docker run --read-only --tmpfs /tmp:size=50m) for directories the application legitimately needs to write to. Verify which directories your application writes to: run with strace -e trace=openat,creat,write docker run -it your-image your-entrypoint to see all filesystem writes during startup and normal operation. Most stateless web applications and API services can run with a read-only root filesystem with only /tmp writable.

Sources & references

  1. Docker Official: Security Best Practices
  2. CIS Docker Benchmark
  3. Trivy: Container Vulnerability Scanner
  4. Google Distroless Container Images

Free resources

25
Free download

Critical CVE Reference Card 2025–2026

25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.

No spam. Unsubscribe anytime.

Free download

Ransomware Incident Response Playbook

Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.

No spam. Unsubscribe anytime.

Free newsletter

Get threat intel before your inbox does.

50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, daily, no spam.

Unsubscribe anytime. We never sell your data.

Eric Bang
Author

Founder & Cybersecurity Evangelist, Decryption Digest

Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals every morning.

Black Hat Giveaway

Win a $2,495 Black Hat pass.

Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.

Joins Decryption Digest daily briefing. Unsubscribe anytime.

Giveaway: Black Hat USA 2026 Full-Access Pass ($2,495 value)

Details →
Daily Briefing

Subscribe to enter the giveaway

Every subscriber is automatically entered. You also get daily threat intel every morning: zero-days, ransomware, and nation-state campaigns. Free. No spam.

Already subscribed? You're already entered.

Giveaway

Win a $2,495 Black Hat USA 2026 pass.