Contents

How to Harden Your Docker Images: A Container Security Checklist

Hardening a Docker image means eliminating the attack surface at every layer. Start from a minimal base image like distroless or Alpine, run as a non-root user, set the filesystem read-only, drop all Linux capabilities and add back only what the application actually needs, pin dependency versions with verified checksums, and scan images with Trivy or Grype before pushing to a registry. Each layer of this checklist is independently valuable. You can adopt them incrementally without rewriting existing Dockerfiles, and every single item you check off reduces your exposure to real-world container exploits.

Most container security incidents trace back to a small set of preventable mistakes. The sections below cover each mistake, why it matters, and the exact commands and Dockerfile patterns to fix it.

Why Docker Images Are a Common Attack Vector

Container vulnerabilities map neatly onto the OWASP Top 10 . Understanding these mappings helps prioritize which hardening steps matter most for your environment.

Vulnerable and outdated components (OWASP A06) account for the majority of production Docker image CVEs. A FROM ubuntu:22.04 base used in a Dockerfile written in 2023 accumulates hundreds of known CVEs unless the image is rebuilt regularly against updated packages. The fix is straightforward - rebuild images frequently and scan them before deployment - but teams that treat Docker images as “build once, run forever” artifacts get burned here constantly.

Broken access control (OWASP A01) is a problem because Docker containers run as root by default. If an attacker exploits a vulnerability inside a container running as root and then finds a container-escape path, they land on the host as root. Running as a non-root user with a UID above 1000 limits the blast radius of any exploitation to the container’s unprivileged user permissions.

Supply chain attacks (OWASP A08) are a serious concern with Docker. Pulling FROM node:latest or FROM python:3.11 without a digest pin means a compromised registry push silently changes your base image. The 2024 Codecov supply chain attack, Docker Hub namespace squatting incidents, and cryptominer injection via typosquatted base image names all demonstrate this pattern. Pinning to SHA256 digests prevents silent substitution.

Secrets in images (OWASP A02) catch teams off guard. API keys and passwords added via ENV or COPY in Dockerfile layers remain visible in docker history output even after you delete them in a later layer. Docker image layers are append-only by design - you cannot truly remove data from an earlier layer. Use Docker BuildKit secrets or multi-stage builds to keep secrets out of final image layers entirely.

Unnecessary network exposure rounds out the common mistakes. A container that does not need to listen on any network port should not EXPOSE one. Remove unnecessary port exposures and use Docker networks to limit container-to-container communication to explicitly permitted paths.

Start with a Minimal Base Image

Every package in a base image is a potential vulnerability. The correct starting point for a hardened image is the smallest image that can actually run the application, even if it is less convenient than a full-featured base.

scratch

The scratch base is the empty image. No shell, no package manager, no libc. It is only viable for statically compiled Go or C binaries. Images built on scratch are typically 5-20MB and have zero CVEs by definition because there are no OS packages to be vulnerable.

FROM scratch
COPY myapp /myapp
ENTRYPOINT ["/myapp"]

Google Distroless

Google Distroless images (gcr.io/distroless/base-debian12) include only libc, CA certificates, and timezone data. No shell, no package manager. Variants exist for Java (JRE), Python, Node.js, and Go. Google maintains these with regular CVE patching. For most production services in 2026, Distroless is the recommended starting point.

Chainguard Images

Chainguard Images (cgr.dev/chainguard/) are rebuilt daily from source with zero known CVEs at build time. They are distroless-compatible and include SBOM and provenance attestations signed with Sigstore /cosign. If your organization needs supply chain security compliance at SLSA Level 3, Chainguard is the strongest option currently available.

Alpine Linux

Alpine Linux weighs in at roughly 5MB and includes the ash shell and apk package manager. The CVE surface is much smaller than Debian or Ubuntu. Alpine works well when you need a debug shell inside the image during development. Choose Alpine over ubuntu:24.04 for smaller images, but prefer Distroless when no shell access is needed in production.

What to Avoid

Avoid FROM ubuntu:latest, FROM debian:latest, FROM python:3.11 (which includes full Debian Bullseye with dev tools), and FROM node:20 (full Debian plus npm plus all Node.js tooling). These convenience images add hundreds of megabytes and thousands of OS packages that inflate your CVE exposure for no production benefit.

Here is a comparison of common base images:

Base ImageSizeApprox. CVE SurfaceShellPackage Manager
scratch0 MBNoneNoNo
gcr.io/distroless/base-debian12~20 MBMinimalNoNo
cgr.dev/chainguard/static~5 MBZero at buildNoNo
alpine:3.19~5 MBLowYesapk
ubuntu:24.04~78 MBHighYesapt
node:20~350 MBVery HighYesapt + npm

Multi-Stage Builds

For compiled applications, use a full builder image in the first stage and copy only the compiled binary to a minimal final stage. The production image never contains the compiler, build tools, or source code.

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o /app/server .

FROM gcr.io/distroless/base-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

This pattern keeps the build environment rich (with all the tools you need to compile) while the runtime image stays minimal and hardened.

Runtime Security - Non-Root Users, Read-Only Filesystems, and Capability Dropping

A hardened Dockerfile also constrains what the running container can do at the OS level. These runtime controls limit the damage an attacker can cause even if they exploit a vulnerability inside the container.

Run as a Non-Root User

Add a dedicated application user and switch to it as the final Dockerfile instruction:

# Alpine
RUN addgroup -S app && adduser -S app -G app
USER app

# Debian/Distroless
RUN useradd --system --no-create-home --uid 10001 appuser
USER appuser

Verify it works with docker run --rm myimage whoami - the output should show your non-root username. This single change blocks an entire class of container-escape attacks that depend on root privileges.

Read-Only Filesystem

Run containers with a read-only root filesystem:

docker run --read-only --tmpfs /tmp myimage

This forces the entire container filesystem to read-only mode. Any attempt to write files fails unless a --tmpfs mount or named volume is explicitly provided for that path. An attacker who gains code execution inside the container cannot write malicious binaries to the filesystem.

In Docker Compose:

services:
  app:
    image: myimage
    read_only: true
    tmpfs:
      - /tmp

Drop All Linux Capabilities

Docker grants a default capability set of 14+ capabilities including NET_RAW (which enables raw socket attacks) and SYS_PTRACE. Drop all of them and add back only what the application actually needs:

docker run --cap-drop ALL --cap-add NET_BIND_SERVICE myimage

A web server that needs to bind port 80 needs NET_BIND_SERVICE and nothing else. A background worker that never opens a socket might need zero capabilities.

In Docker Compose:

services:
  app:
    image: myimage
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

Prevent Privilege Escalation

The --no-new-privileges flag prevents processes inside the container from gaining additional privileges via setuid or setgid binaries:

docker run --security-opt no-new-privileges:true myimage

Or in Docker Compose:

services:
  app:
    image: myimage
    security_opt:
      - no-new-privileges:true

Seccomp and AppArmor Profiles

Docker’s default seccomp profile blocks 44 dangerous system calls out of the box. For maximum hardening, generate a custom seccomp profile that allows only the exact syscalls your application uses. Tools like strace or Falco can help identify required syscalls during development.

Docker’s default AppArmor profile (docker-default) provides basic Mandatory Access Control enforcement. For production environments, consider writing a custom AppArmor profile that whitelists specific file and network accesses for your application binary.

Dependency Pinning, Checksums, and Build Reproducibility

Unpinned dependencies cause both security drift and non-reproducible builds. When the same Dockerfile produces different images on Monday and Friday because an upstream dependency changed, you lose the ability to audit what is actually running in production.

Pin Base Images by Digest

Instead of FROM gcr.io/distroless/base-debian12:latest, pin to an exact digest:

FROM gcr.io/distroless/base-debian12@sha256:6ae5fe659f...

This guarantees the exact bytes pulled every time. Combine with Renovate Bot or Dependabot to automate digest updates when new patched images are published. You get reproducibility without falling behind on security patches.

Language-Specific Pinning

For Python, use pip-tools or uv with a lockfile. Add --require-hashes to pip install commands so package content is verified against known-good checksums:

RUN pip install --require-hashes -r requirements.lock

For Node.js, always use npm ci or pnpm install --frozen-lockfile in Dockerfiles, never npm install which can ignore the lockfile. Commit package-lock.json or pnpm-lock.yaml to version control.

For Go, the go.sum file automatically verifies all module checksums against the Go checksum database during go mod download. The Go toolchain enforces this by default - no additional configuration needed.

SBOM Generation and Image Signing

Generate a Software Bill of Materials as part of your build:

docker buildx build --sbom=true --provenance=true -t myimage .

This creates an SBOM as an OCI attestation attached to the image manifest, which is increasingly required for enterprise compliance in SLSA Level 2+ environments.

Sign your images with cosign :

cosign sign --key cosign.key myregistry/myimage:tag

CI pipelines can then verify the signature with cosign verify before deploying to production, preventing unsigned or tampered images from reaching production registries.

Scanning and CI Integration with Trivy and Grype

Image hardening is not a one-time activity. New CVEs are published daily against packages that were clean at build time. Integrating a scanner into the CI pipeline makes vulnerability detection continuous.

Trivy

Trivy is the most widely used open-source container scanner in 2026. It scans for OS CVEs, language package CVEs, misconfigurations, and embedded secrets:

trivy image --severity HIGH,CRITICAL --exit-code 1 myimage:tag

The --exit-code 1 flag fails the CI pipeline when findings are detected. Trivy is free and open source under the Apache 2.0 license, maintained by Aqua Security .

Trivy Kubernetes cluster scan results showing a summary of vulnerabilities, misconfigurations, and secrets across workloads
Trivy scanning a Kubernetes cluster โ€” showing vulnerability counts per workload with severity breakdown
Image: Aqua Security Trivy

Grype as a Second Opinion

Grype provides a complementary vulnerability database from Anchore:

grype myimage:tag --fail-on high
Grype vulnerability scan in action โ€” matching CVEs against Anchore's vulnerability database

Running both Trivy and Grype catches vulnerabilities that may appear in one database before the other. Consider running Grype in a weekly scheduled scan separate from the Trivy commit-gate scan to balance thoroughness with pipeline speed.

GitHub Actions Integration

Use the official Trivy action in your build-and-scan workflow:

- name: Scan image
  uses: aquasecurity/trivy-action@v0.20.0
  with:
    image-ref: myimage:${{ github.sha }}
    severity: HIGH,CRITICAL
    exit-code: 1
    format: sarif
    output: trivy-results.sarif

- name: Upload results
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: trivy-results.sarif

This uploads SARIF results to the GitHub Security tab where security teams see findings alongside code scanning alerts.

GitLab CI Integration

For GitLab, run Trivy in a Docker-in-Docker stage:

container_scan:
  stage: test
  image: aquasec/trivy:latest
  script:
    - trivy image --severity HIGH,CRITICAL --exit-code 1 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  artifacts:
    reports:
      container_scanning: trivy-report.json

Results appear in merge request security widgets for review before merging.

Runtime Scanning with Falco

Static image scanning has a blind spot: it cannot detect attacks that happen at runtime. Falco is an eBPF-based syscall monitoring tool that catches unexpected outbound connections, new process spawns, and file writes in sensitive directories while the container is running. Combining Falco with static scanning gives you both pre-deployment and runtime security coverage.

Putting It All Together

Here is a complete hardened Dockerfile for a Node.js API that applies most items from this checklist:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM gcr.io/distroless/nodejs20-debian12
COPY --from=builder /app/dist /app
COPY --from=builder /app/node_modules /app/node_modules
WORKDIR /app
USER nonroot
EXPOSE 3000
CMD ["server.js"]

And the corresponding Docker Compose configuration with runtime hardening:

services:
  api:
    build: .
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true
    ports:
      - "3000:3000"

None of these hardening steps require exotic tooling or deep kernel knowledge. Each one is a concrete change to a Dockerfile or a docker run command. Start with the items that have the highest impact for the least effort - non-root user, minimal base image, and a Trivy scan in CI - and work through the rest of the checklist as your team’s container security practices mature. Every new image you ship should be meaningfully harder to exploit than the last one, and this checklist gets you there incrementally.