Dependency & Container Security
Base Image Hardening & Distroless Containers
The most effective way to reduce container vulnerabilities is to minimize what's inside. Less software = fewer vulnerabilities = smaller attack surface.
The Problem with Standard Images
A typical node:20 image contains:
node:20 (debian-based)
├── Node.js runtime
├── npm/yarn
├── Bash shell
├── apt package manager
├── curl, wget
├── gcc compiler
├── Python (build tools)
└── 400+ MB of OS packages
Result: 200-500 CVEs, most unrelated to your application.
Image Size Comparison
| Base Image | Size | Typical CVEs |
|---|---|---|
node:20 |
1.1 GB | 200-500 |
node:20-slim |
250 MB | 50-150 |
node:20-alpine |
140 MB | 10-50 |
gcr.io/distroless/nodejs20 |
130 MB | 0-10 |
scratch + static binary |
~10 MB | 0 |
Hardening Strategy 1: Alpine Images
Alpine Linux uses musl libc instead of glibc, resulting in tiny images:
# Instead of node:20
FROM node:20-alpine
# Install only what you need
RUN apk add --no-cache dumb-init
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Run as non-root
USER node
CMD ["dumb-init", "node", "server.js"]
Pros: Small, fewer CVEs, widely supported Cons: Some npm packages fail (native bindings expect glibc)
Hardening Strategy 2: Distroless Images
Distroless images contain only your application and its runtime dependencies—no shell, no package manager, no extras.
# Build stage
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage - distroless
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY /app/dist ./dist
COPY /app/node_modules ./node_modules
CMD ["dist/server.js"]
Available distroless images:
gcr.io/distroless/static- For statically compiled binaries (Go, Rust)gcr.io/distroless/base- glibc for dynamically linked binariesgcr.io/distroless/nodejs20- Node.js runtimegcr.io/distroless/python3- Python runtimegcr.io/distroless/java21- Java runtime
Hardening Strategy 3: Multi-Stage Builds
Separate build-time dependencies from runtime:
# Stage 1: Build
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server
# Stage 2: Runtime (scratch = empty image)
FROM scratch
COPY /server /server
COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]
Security Best Practices Checklist
# 1. Pin specific versions (not :latest)
FROM node:20.10.0-alpine3.19
# 2. Create non-root user
RUN addgroup -g 1001 appgroup && \
adduser -u 1001 -G appgroup -D appuser
# 3. Set ownership
COPY . .
# 4. Drop all capabilities
USER appuser
# 5. Use read-only filesystem (at runtime)
# docker run --read-only my-app
# 6. No new privileges
# docker run --security-opt=no-new-privileges my-app
# 7. Health check
HEALTHCHECK \
CMD wget -q --spider http://localhost:8080/health || exit 1
Scanning Hardened Images
Compare before and after:
# Before (standard image)
trivy image node:20
# Total: 487 (HIGH: 42, CRITICAL: 8)
# After (distroless)
trivy image gcr.io/distroless/nodejs20
# Total: 3 (HIGH: 0, CRITICAL: 0)
Chainguard Images
Chainguard provides hardened, SBOM-included images:
# Chainguard Node.js (zero CVEs)
FROM cgr.dev/chainguard/node:latest
| Feature | Distroless | Chainguard |
|---|---|---|
| Zero CVEs | Often | Guaranteed |
| SBOM included | No | Yes |
| Signed images | No | Yes (Sigstore) |
| Support | Community | Commercial |
Key Takeaways
- Start small: Use
-alpine,-slim, or distroless - Multi-stage builds: Separate build from runtime
- Pin versions: Never use
:latestin production - Run as non-root: Always specify USER
- Scan regularly: CVEs appear in base images daily
In the next module, we'll explore dynamic testing with DAST and runtime security. :::