Container Security Scanning Best Practices: Securing Docker Images from Build to Production
From base image selection to runtime security, a comprehensive guide to scanning and securing your containerized applications.
SafeWeave Team
The rapid adoption of containerized deployments has fundamentally changed how software teams ship code. Containers offer consistency, portability, and speed -- but they also introduce an entirely new category of security risks that traditional application security tools were never designed to address. A single misconfigured Dockerfile, a bloated base image harboring dozens of known CVEs, or an exposed container runtime can turn your orchestrated infrastructure into an attacker's playground.
According to recent industry research, over 50 percent of container images in public registries contain at least one critical or high-severity vulnerability. The problem is compounded by the speed of modern development: when AI coding assistants can scaffold entire Dockerized applications in seconds, the attack surface expands faster than manual review processes can keep up. This is the reality that makes container security scanning not just a best practice but an operational necessity.
This guide walks through the full lifecycle of container security -- from choosing base images to runtime monitoring -- with practical examples, references to OWASP and CWE classifications, and actionable strategies you can implement today.
Understanding the Container Threat Landscape
Before diving into scanning practices, it is important to understand what makes containers uniquely vulnerable compared to traditional virtual machines or bare-metal deployments.
The Immutable Image Problem
Containers are built from images, and images are built from layers. Each layer can introduce vulnerabilities: an outdated system library in the base image, a development tool left behind in a build stage, or a configuration file with hardcoded credentials. Unlike a traditional server where you can SSH in and patch a library, container images are meant to be immutable. If you do not scan before deployment, you are shipping known vulnerabilities into production with no easy mechanism to patch them in place.
Supply Chain Risks in Base Images
Every Dockerfile begins with a FROM statement, and that single line can pull in hundreds of packages you never explicitly chose. The OWASP Top 10 for containers identifies "Vulnerable and Outdated Components" (aligned with OWASP A06:2021) as one of the most prevalent risks. When you write FROM python:3.11, you are inheriting the entire Debian or Alpine package tree, along with whatever vulnerabilities exist in those packages at that point in time.
Consider this common scenario:
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
This seemingly simple Dockerfile pulls in the full Node.js image based on Debian Bookworm, which typically contains over 400 OS-level packages. A scan of this image might reveal dozens of CVEs, many of which have nothing to do with your application but exist in system libraries like libssl, zlib, or glibc.
Common Container Vulnerabilities by Category
Container vulnerabilities generally fall into several categories, each mapped to CWE identifiers:
- OS Package Vulnerabilities (CWE-1104): Outdated libraries inherited from base images
- Application Dependency Vulnerabilities (CWE-1035): npm, pip, Maven, or Go module vulnerabilities baked into the image
- Misconfigurations (CWE-16): Running as root, exposing unnecessary ports, missing health checks
- Secrets Exposure (CWE-798): API keys, database passwords, or tokens embedded in image layers
- Excessive Permissions (CWE-250): Containers running with elevated privileges they do not need
Choosing Secure Base Images
The foundation of container security begins with your base image selection. This single decision has cascading effects on your entire security posture.
Minimal Base Images
The principle is straightforward: fewer packages mean fewer potential vulnerabilities. Instead of using a full OS image, consider these alternatives:
# Instead of this (hundreds of packages, large attack surface)
FROM ubuntu:22.04
# Use this (minimal, ~5MB, musl-based)
FROM alpine:3.19
# Or this (Google's distroless - no shell, no package manager)
FROM gcr.io/distroless/static-debian12
Google's Distroless images deserve special mention. They contain only your application and its runtime dependencies -- no shell, no package manager, no unnecessary utilities. This dramatically reduces the attack surface. An attacker who compromises a distroless container cannot easily escalate privileges because common tools like curl, wget, sh, and bash simply do not exist in the image.
Language-Specific Slim Variants
Most official language images offer slim variants that remove development tools, documentation, and rarely needed packages:
# Full image: ~900MB, hundreds of CVEs possible
FROM python:3.12
# Slim variant: ~150MB, significantly reduced attack surface
FROM python:3.12-slim
# Alpine variant: ~50MB, minimal packages
FROM python:3.12-alpine
The tradeoff with Alpine is that it uses musl instead of glibc, which can cause compatibility issues with certain Python packages that rely on C extensions. Test thoroughly before committing to Alpine for Python workloads.
Pinning Image Digests
Tags are mutable -- someone can push a new image to the same tag. For production security, pin images by their SHA256 digest:
# Tag-based (mutable, could change under you)
FROM node:18-slim
# Digest-based (immutable, guaranteed reproducibility)
FROM node:18-slim@sha256:a1b2c3d4e5f6...
This practice prevents supply chain attacks where a compromised image is pushed to a trusted tag. It also ensures build reproducibility, which is critical for auditing and compliance requirements under frameworks like SOC 2 and PCI DSS.
Catch these vulnerabilities automatically with SafeWeave
SafeWeave runs 8 security scanners in parallel — SAST, secrets, dependencies, IaC, containers, DAST, license, and posture — right inside your AI editor. One command, zero config.
Start Scanning FreeDockerfile Security Best Practices
Your Dockerfile is infrastructure as code, and it should be treated with the same security rigor as your application code.
Run as Non-Root
By default, containers run as root (UID 0). This is CWE-250 (Execution with Unnecessary Privileges) in action. If an attacker escapes the application layer, they land as root inside the container, which makes further escalation significantly easier.
FROM node:18-slim
# Create a non-root user
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
WORKDIR /app
COPY --chown=appuser:appuser package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appuser . .
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Multi-Stage Builds
Multi-stage builds are one of the most effective security techniques available. They ensure that build tools, compilers, test frameworks, and other development dependencies never make it into the production image.
# Build stage - contains all build tools
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
# Production stage - minimal runtime only
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
The production image in this example contains only a single static binary. No Go compiler, no source code, no build cache, no shell. The attack surface is virtually zero beyond the application itself.
Avoiding Secret Leakage in Layers
Every RUN, COPY, and ADD instruction creates a new image layer. Even if you delete a file in a subsequent layer, the previous layer still contains it. This is a common source of CWE-798 (Use of Hard-coded Credentials) in container environments.
# WRONG: Secret exists in image layer history even after deletion
FROM alpine:3.19
COPY credentials.json /tmp/credentials.json
RUN ./setup.sh && rm /tmp/credentials.json
# RIGHT: Use build secrets (BuildKit)
FROM alpine:3.19
RUN --mount=type=secret,id=credentials ./setup.sh
Docker BuildKit secrets are mounted only during the build step and never written to any image layer. Use them for anything sensitive: npm tokens, SSH keys, API credentials, and database connection strings.
Additional Dockerfile Hardening
Several other practices reduce your attack surface:
# Use COPY instead of ADD (ADD can auto-extract tarballs and fetch URLs)
COPY requirements.txt .
# Set a read-only filesystem where possible
# (configured at runtime, not in Dockerfile)
# Drop all capabilities and add only what you need
# (configured in orchestrator, e.g., Kubernetes securityContext)
# Use .dockerignore to prevent sensitive files from entering the build context
A comprehensive .dockerignore file is critical:
.git
.env
*.pem
*.key
credentials*
docker-compose*.yml
node_modules
.coverage
__pycache__
Container Scanning Tools and Approaches
Container scanning tools fall into several categories based on what they examine and when they run.
Image Vulnerability Scanning
These tools analyze the contents of a container image -- OS packages, application dependencies, and sometimes configuration -- against known vulnerability databases.
Trivy is one of the most widely adopted open-source container scanners. It scans for OS package vulnerabilities, application dependency issues, misconfigurations, and exposed secrets:
# Scan a local image
trivy image myapp:latest
# Scan with severity filtering
trivy image --severity HIGH,CRITICAL myapp:latest
# Scan and output JSON for CI/CD integration
trivy image --format json --output results.json myapp:latest
# Scan a Dockerfile for misconfigurations
trivy config Dockerfile
Grype (from Anchore) is another excellent option that focuses specifically on vulnerability matching:
# Scan an image
grype myapp:latest
# Scan with specific fail conditions
grype myapp:latest --fail-on high
Tools like SafeWeave integrate container scanning as part of a broader security scanning pipeline. Rather than running isolated container scans, SafeWeave orchestrates Trivy alongside SAST, secrets detection, dependency analysis, and IaC scanning through a single interface. This unified approach is particularly valuable when AI-generated code includes Dockerfiles along with application code -- you want to catch issues across all layers simultaneously.
Static Dockerfile Analysis
Before you even build an image, you can analyze the Dockerfile itself for security issues and best practice violations.
Hadolint is the de facto standard for Dockerfile linting:
# Lint a Dockerfile
hadolint Dockerfile
# Common findings:
# DL3007 - Using latest tag
# DL3002 - Last USER should not be root
# DL3008 - Pin versions in apt-get install
# DL3018 - Pin versions in apk add
# SC2086 - Double quote to prevent globbing
Checkov and KICS can also scan Dockerfiles alongside other IaC files:
# Checkov Dockerfile scan
checkov -f Dockerfile --framework dockerfile
# KICS scan
kics scan -p Dockerfile -t Dockerfile
Software Bill of Materials (SBOM)
Generating an SBOM for your container images provides a complete inventory of everything inside the image. This is increasingly required for compliance and is essential for responding quickly when new CVEs are disclosed.
# Generate SBOM with Syft
syft myapp:latest -o spdx-json > sbom.json
# Generate SBOM with Trivy
trivy image --format spdx-json myapp:latest > sbom.json
# Later, scan the SBOM for new vulnerabilities
grype sbom:sbom.json
When a new critical CVE is announced (like Log4Shell, CVE-2021-44228), having SBOMs for all your deployed images lets you immediately determine which images are affected without rescanning everything.
CI/CD Integration Strategies
Scanning containers in CI/CD pipelines transforms security from a manual checkpoint into an automated gate.
Build-Time Scanning
The most common integration point is scanning images immediately after they are built, before they are pushed to a registry:
# GitHub Actions example
name: Build and Scan
on: [push]
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: HIGH,CRITICAL
exit-code: 1
format: sarif
output: trivy-results.sarif
- name: Upload scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
- name: Push to registry (only if scan passes)
if: success()
run: |
docker tag myapp:${{ github.sha }} registry.example.com/myapp:${{ github.sha }}
docker push registry.example.com/myapp:${{ github.sha }}
Registry-Level Scanning
Major container registries offer built-in scanning that automatically analyzes images when they are pushed:
- Amazon ECR: Enhanced scanning powered by Amazon Inspector, integrates with AWS Security Hub
- Google Artifact Registry: On-push and continuous scanning with Container Analysis
- Azure Container Registry: Microsoft Defender for Containers provides continuous vulnerability assessment
- Docker Hub: Docker Scout provides automated vulnerability analysis
- Harbor: Open-source registry with integrated Trivy scanning
Registry-level scanning provides a safety net, but it should complement -- not replace -- build-time scanning in your pipeline.
Admission Control in Kubernetes
For organizations running Kubernetes, admission controllers provide a final gate that can prevent vulnerable images from being deployed:
# Example Kyverno policy to block images with critical CVEs
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: check-image-vulnerabilities
spec:
validationFailureAction: Enforce
rules:
- name: check-vulnerabilities
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "registry.example.com/*"
attestations:
- type: https://cosign.sigstore.dev/attestation/vuln/v1
conditions:
- all:
- key: "{{ scanner_result.max_severity }}"
operator: NotEquals
value: "CRITICAL"
Runtime Container Security
Scanning images at build time catches known vulnerabilities, but runtime security addresses threats that emerge after deployment: zero-day exploits, compromised processes, anomalous network activity, and privilege escalation attempts.
Read-Only File Systems
Running containers with read-only root filesystems prevents attackers from writing malware, modifying binaries, or installing additional tools:
# Kubernetes Pod spec
apiVersion: v1
kind: Pod
metadata:
name: secure-app
spec:
containers:
- name: app
image: myapp:latest
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /var/log/app
volumes:
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
Security Contexts and Pod Security Standards
Kubernetes Pod Security Standards define three profiles -- Privileged, Baseline, and Restricted -- that enforce increasingly strict security requirements:
# Namespace-level enforcement
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
The restricted profile enforces non-root execution, drops all capabilities, blocks privilege escalation, and requires read-only root filesystems. This aligns with CWE-250 (Execution with Unnecessary Privileges) and provides defense-in-depth against container breakout attacks.
Runtime Threat Detection
Tools like Falco monitor container behavior at the system call level and alert on suspicious activity:
# Falco rule: detect shell spawned in container
- rule: Terminal shell in container
desc: A shell was spawned in a container
condition: >
spawned_process and container and
shell_procs and proc.pname != java
output: >
Shell spawned in container
(user=%user.name container=%container.name
shell=%proc.name parent=%proc.pname)
priority: WARNING
This type of runtime monitoring catches attacks that static scanning cannot: an attacker exploiting a zero-day vulnerability to gain shell access, a process reading files it should not access, or unusual network connections to command-and-control servers.
Vulnerability Management and Prioritization
Not every CVE requires immediate action. Effective container security requires a risk-based approach to vulnerability management.
Understanding CVSS and Exploitability
The Common Vulnerability Scoring System (CVSS) provides a base score, but context matters enormously. A critical CVE in a library that your application never calls is less urgent than a medium-severity vulnerability in a function you invoke on every request.
Consider these factors when prioritizing:
- Is there a known exploit in the wild? Check CISA's Known Exploited Vulnerabilities (KEV) catalog
- Is the vulnerable component reachable? If the vulnerable function is never called, the effective risk is lower
- Is the container exposed to the network? An internal-only container has a different risk profile than an internet-facing one
- What is the blast radius? A vulnerability in a container that processes sensitive data demands faster remediation
Establishing Remediation SLAs
Define clear remediation timelines based on severity:
| Severity | CVSS Range | Remediation SLA | Example |
|---|---|---|---|
| Critical | 9.0-10.0 | 48 hours | CVE-2021-44228 (Log4Shell) |
| High | 7.0-8.9 | 7 days | CVE-2023-44487 (HTTP/2 Rapid Reset) |
| Medium | 4.0-6.9 | 30 days | CVE-2023-45853 (zlib memory corruption) |
| Low | 0.1-3.9 | 90 days | Information disclosure in error messages |
Handling Unfixable Vulnerabilities
Some CVEs in base images have no available fix. In these cases, document exceptions with a risk acceptance process:
{
"vulnerability": "CVE-2023-XXXXX",
"package": "libexpat1",
"severity": "medium",
"status": "accepted",
"justification": "Package is not called by application code. No fix available in current Debian stable. Mitigated by network policy restricting container egress.",
"accepted_by": "security-team",
"review_date": "2026-06-15"
}
Many scanning tools support ignore files or exception lists. Trivy uses .trivyignore, Grype uses a YAML configuration file, and SafeWeave allows you to configure severity thresholds and exclusion patterns through its configuration to reduce noise while maintaining security posture.
Signing and Verifying Container Images
Image signing ensures that the image you deploy is the same image that was built and scanned. Without signing, an attacker who compromises your registry can replace images with malicious versions.
Cosign and Sigstore
Cosign, part of the Sigstore project, provides keyless signing using OIDC identity:
# Sign an image (keyless, uses OIDC)
cosign sign registry.example.com/myapp:v1.0.0
# Verify an image
cosign verify registry.example.com/myapp:v1.0.0 \
--certificate-identity=ci@example.com \
--certificate-oidc-issuer=https://accounts.google.com
# Attach a vulnerability attestation
cosign attest --predicate scan-results.json \
--type vuln \
registry.example.com/myapp:v1.0.0
By combining signing with admission control, you create a chain of trust: only images that were built by your CI/CD pipeline, scanned for vulnerabilities, and signed can be deployed to production.
Try SafeWeave in 30 seconds
npx safeweave-mcp
Works with Cursor, Claude Code, Windsurf, and VS Code. No signup required for the free tier — 3 scanners, unlimited scans.
Building a Container Security Program
Container security is not a one-time effort but an ongoing program. Here is a practical roadmap for organizations at different maturity levels.
Level 1: Foundation
- Adopt minimal base images (Alpine, slim, or distroless)
- Implement multi-stage builds
- Run containers as non-root
- Scan images in CI/CD before pushing to registry
- Maintain a
.dockerignorefile
Level 2: Intermediate
- Pin base image digests
- Generate and store SBOMs for all production images
- Implement registry-level scanning with continuous monitoring
- Define and enforce remediation SLAs
- Use Docker BuildKit secrets for sensitive build arguments
Level 3: Advanced
- Implement image signing and verification with admission control
- Deploy runtime security monitoring (Falco or similar)
- Enforce Pod Security Standards at the namespace level
- Integrate vulnerability data into a centralized security dashboard
- Automate base image updates with tools like Renovate or Dependabot
Conclusion
Container security scanning is a multi-layered discipline that spans the entire software delivery lifecycle. It begins with choosing the right base image, extends through Dockerfile hardening and CI/CD scanning gates, and continues with runtime monitoring and incident response. The pace of AI-assisted development -- where entire containerized applications can be generated in minutes -- makes automated scanning not optional but essential.
The key takeaway is that no single tool or practice is sufficient. Effective container security requires defense in depth: minimal images reduce the attack surface, multi-stage builds eliminate unnecessary components, CI/CD scanning catches known vulnerabilities before deployment, admission control enforces policies at the cluster level, and runtime monitoring detects threats that static analysis cannot predict.
By integrating container scanning into your existing development workflow -- whether through standalone tools like Trivy and Grype, or through unified security platforms like SafeWeave that orchestrate multiple scanners in a single pipeline -- you transform container security from an afterthought into a foundational practice. Start with the basics, measure your progress, and iterate. Your containers will never be perfectly secure, but they can be defensibly secure.
Secure your AI-generated code with SafeWeave
8 security scanners running in parallel, right inside your AI editor. SAST, secrets, dependencies, IaC, containers, DAST, license compliance, and security posture — all in one command.
No credit card required · 3 scanners free forever · Runs locally on your machine