Dependency & Container Security

Base Image Hardening & Distroless Containers

3 min read

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 --from=builder /app/dist ./dist
COPY --from=builder /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 binaries
  • gcr.io/distroless/nodejs20 - Node.js runtime
  • gcr.io/distroless/python3 - Python runtime
  • gcr.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 --from=builder /server /server
COPY --from=builder /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 --chown=appuser:appgroup . .

# 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 --interval=30s --timeout=3s \
  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

  1. Start small: Use -alpine, -slim, or distroless
  2. Multi-stage builds: Separate build from runtime
  3. Pin versions: Never use :latest in production
  4. Run as non-root: Always specify USER
  5. Scan regularly: CVEs appear in base images daily

In the next module, we'll explore dynamic testing with DAST and runtime security. :::

Quiz

Module 3 Quiz: Dependency & Container Security

Take Quiz