Skip to main content

Image Security and Supply Chain

Attackers don't always create new vulnerabilities -- they exploit known ones in your base images. A single unpatched dependency can give an attacker a foothold. This lesson covers how to choose secure base images, scan for vulnerabilities, and pin images for reproducibility.

Choose Minimal Base Images

The more packages in your image, the larger the attack surface:

Base ImageSizeShell?Pkg Manager?Attack Surface
node:18~1 GBaptHigh -- hundreds of unused binaries
node:18-alpine~170 MB✓ (sh)apkMedium -- reduced surface
gcr.io/distroless/nodejs~130 MBLow -- runtime only
flowchart LR
Full["Full Image<br/>1 GB, 400+ packages"] -->|"Reduce"| Alpine["Alpine<br/>170 MB, ~30 packages"]
Alpine -->|"Minimize"| Distroless["Distroless<br/>130 MB, runtime only"]

style Full fill:#ffebee,stroke:#c62828
style Alpine fill:#fff3e0,stroke:#ef6c00
style Distroless fill:#e8f5e9,stroke:#2e7d32
Start with Alpine

Alpine is a good default for most workloads. Move to Distroless if you can operate without a shell for debugging.

Use Multi-Stage Builds

Keep build tools out of your final image:

# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build

# Stage 2: Runtime (no build tools)
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER 1000
CMD ["node", "dist/server.js"]

The final image contains only the compiled application and production dependencies.

Scan for Vulnerabilities

Scan every image before deploying. Never deploy an image with critical CVEs.

Scanning Tools

ToolTypeCommand
Docker ScoutBuilt-indocker scout quickview my-app:1.0.0
TrivyOpen source, CI-friendlytrivy image my-app:1.0.0
SnykCommercial, fix suggestionssnyk container test my-app:1.0.0

Example: Trivy Scan

# Scan a local image
trivy image my-app:1.0.0

# Or scan without installing Trivy
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image my-app:1.0.0

Example output:

Total: 34 (UNKNOWN: 0, LOW: 20, MEDIUM: 10, HIGH: 3, CRITICAL: 1)

┌───────────────┬────────────────┬──────────┬──────────────────┐
│ Library │ Vulnerability │ Severity │ Fixed Version │
├───────────────┼────────────────┼──────────┼──────────────────┤
│ openssl │ CVE-2024-XXXX │ CRITICAL │ 3.1.5-r0 │
│ curl │ CVE-2024-YYYY │ HIGH │ 8.5.0-r0 │
└───────────────┴────────────────┴──────────┴──────────────────┘

Action: If you see CRITICAL, update the affected package or base image before deploying.

Pin Images by Digest

Tags are mutable -- nginx:latest can point to a different image each day. Pin production images by digest for immutability:

# Mutable (bad for production)
FROM node:18-alpine

# Immutable (good for production)
FROM node:18-alpine@sha256:8c5b2f3a1e2d...

Get the digest:

docker inspect --format='{{index .RepoDigests 0}}' node:18-alpine

Image Security Workflow

flowchart TD
Build["Build image<br/>docker build -t app:1.0.0 ."] --> Scan["Scan for CVEs<br/>trivy image app:1.0.0"]
Scan -->|"Critical/High?"| Fix["Fix: update base<br/>or patch package"]
Scan -->|"Clean"| Tag["Tag + push<br/>docker push registry/app:1.0.0"]
Fix --> Scan
Tag --> Deploy["Deploy"]

style Fix fill:#ffebee,stroke:#c62828
style Deploy fill:#e8f5e9,stroke:#2e7d32

Key Takeaways

  • Use minimal base images (Alpine or Distroless) to reduce attack surface.
  • Use multi-stage builds to keep build tools out of the final image.
  • Scan every image for CVEs before deploying. Block critical findings.
  • Pin images by digest in production for immutability -- tags are mutable.
  • Re-scan images periodically. New CVEs are discovered after build time.

What's Next