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 Image | Size | Approx. CVE Surface | Shell | Package Manager |
|---|---|---|---|---|
scratch | 0 MB | None | No | No |
gcr.io/distroless/base-debian12 | ~20 MB | Minimal | No | No |
cgr.dev/chainguard/static | ~5 MB | Zero at build | No | No |
alpine:3.19 | ~5 MB | Low | Yes | apk |
ubuntu:24.04 | ~78 MB | High | Yes | apt |
node:20 | ~350 MB | Very High | Yes | apt + 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 appuserVerify 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 myimageThis 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:
- /tmpDrop 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 myimageA 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_SERVICEPrevent 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 myimageOr in Docker Compose:
services:
app:
image: myimage
security_opt:
- no-new-privileges:trueSeccomp 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.lockFor 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:tagCI 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:tagThe --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
.

Grype as a Second Opinion
Grype provides a complementary vulnerability database from Anchore:
grype myimage:tag --fail-on highRunning 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.sarifThis 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.jsonResults 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.