Most teams add a Trivy scan to their CI pipeline, see a wall of CVEs, ignore most of them, and call it "container security." That's not security — it's checkbox compliance. Real container security is a multi-layer pipeline that filters noise, enforces policies, and blocks deployments that don't meet your standards.
The Layers of Container Security
Container security isn't one thing. It's at least five:
- Base image selection — which OS and runtime you start from
- Dependency scanning — CVEs in your application dependencies
- Image scanning — CVEs in the final built image
- Configuration analysis — Dockerfile best practices and misconfigurations
- Runtime policies — what the container is allowed to do when it runs
Layer 1: Base Image Selection
Your base image choice determines 80% of your vulnerability surface. Alpine has fewer packages (and fewer CVEs) than Ubuntu. Distroless has even fewer.
# Bad: full Ubuntu image — 400+ packages, many unnecessary
FROM ubuntu:22.04
# Better: Alpine — minimal package set
FROM node:20-alpine
# Best: Distroless — only your app and its runtime
FROM gcr.io/distroless/nodejs20-debian12Tracking base image CVE counts as a metric is a good baseline:
#!/bin/bash
for image in "ubuntu:22.04" "node:20-alpine" "gcr.io/distroless/nodejs20-debian12"; do
COUNT=$(trivy image --quiet --severity HIGH,CRITICAL "$image" 2>/dev/null | grep "Total:" | awk '{print $2}')
echo "$image: $COUNT high/critical CVEs"
doneLayer 2: Dependency Scanning
Before you even build the image, scan your application dependencies:
jobs:
dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Audit npm dependencies
run: npm audit --audit-level=high
- name: Check for known vulnerabilities
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
scan-ref: .
severity: HIGH,CRITICAL
exit-code: 1Layer 3: Image Scanning
After building the image, scan it for OS-level and application-level vulnerabilities:
image-scan:
runs-on: ubuntu-latest
needs: build
steps:
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE }}:${{ github.sha }}
severity: HIGH,CRITICAL
exit-code: 1
format: sarif
output: trivy-results.sarif
- name: Upload to GitHub Security
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarifHandling the CVE Noise
The raw scan output is overwhelming. Most CVEs have no fix available, or they're in packages the app doesn't use. A .trivyignore file handles acknowledged risks:
# No fix available, not exploitable in our context
CVE-2023-44487
# Fixed in next base image update, scheduled for March
CVE-2024-21626
# False positive — we don't use the affected function
CVE-2024-34156Every ignored CVE must have a comment explaining why. This file is reviewed in every security audit.
Layer 4: Dockerfile Analysis
Static analysis catches misconfigurations before the image is even built:
# Hadolint — Dockerfile linter
hadolint DockerfileCommon issues Hadolint catches:
# DL3007: Using latest is prone to errors
FROM node:latest
# DL3003: Use WORKDIR instead of cd
RUN cd /app && npm install
# DL3009: Delete apt-get lists after installing
RUN apt-get update && apt-get install -y curl
# DL3018: Pin versions in apk add
RUN apk add curlThe fixed version:
FROM node:20-alpine@sha256:abc123
WORKDIR /app
RUN apk add --no-cache curl=8.5.0-r0
COPY package*.json ./
RUN npm ci --production
COPY . .
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]Layer 5: Runtime Policies
Scanning the image isn't enough — you also need to restrict what it can do at runtime:
apiVersion: v1
kind: Pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
limits:
memory: 512Mi
cpu: 500mThe Complete Pipeline
Putting it all together:
PR Created
├── Dependency scan (npm audit + Trivy filesystem)
├── Dockerfile lint (Hadolint)
└── Unit tests
PR Merged
├── Build image
├── Image scan (Trivy)
│ ├── CRITICAL CVEs → Block deployment
│ ├── HIGH CVEs → Warn, require approval
│ └── MEDIUM/LOW → Log, proceed
├── Sign image (Cosign)
└── Push to registry
Deploy
├── Verify image signature
├── Check admission policies (OPA/Kyverno)
└── Apply with security contextsKey Takeaways
- Start with the base image — Alpine or Distroless eliminates most CVEs before you write any code
- Pin base image digests — reproducible builds prevent surprise vulnerabilities
- Filter scan noise with
.trivyignore— but require justification for every entry - Lint Dockerfiles — misconfigurations are as dangerous as CVEs
- Enforce runtime security contexts — scanning without runtime restrictions is half the picture