Container = image + config + runtime. Every component is a vuln surface.
Base images
| Base | Size | Use |
|---|---|---|
ubuntu:24.04 | ~77 MB | Dev |
node:22-slim | ~180 MB | Node |
gcr.io/distroless/nodejs22 | ~130 MB | Prod |
alpine:3.20 | ~7 MB | Minimal |
scratch | 0 MB | Static Go |
Distroless: No shell, no package manager. Attacker gets code execution - can't easily explore.
Multi-stage builds
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs22
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]
Final: app + prod deps. No build tools, source.
Non-root
RUN addgroup --system app && adduser --system --ingroup app app
USER app
Pin versions
# GOOD
FROM node:22.5.1-slim@sha256:abc123...
No secrets in layers
# BAD
COPY .env /app/.env # Persists in layer history
# GOOD - BuildKit secrets
RUN --mount=type=secret,id=db_password \
DB_PASSWORD=$(cat /run/secrets/db_password) npm run setup
Scanning
trivy image myapp:latest
grype myapp:latest
CI: fail on CRITICAL/HIGH. But scanners only find known CVEs - not your code vulns or misconfigs.
Signing
cosign sign --key cosign.key myregistry.com/myapp:latest
cosign verify --key cosign.pub myregistry.com/myapp:latest
Runtime
Read-only filesystem: read_only: true
Drop capabilities: capabilities: drop: ["ALL"]
Network policies: Restrict pod communication.
Resource limits: Prevent DoS.
The takeaway
Minimal images. Multi-stage. Non-root. Pin digests. No secrets in layers. Scan in CI. Sign and verify.
Runtime: read-only filesystem, drop capabilities, network policies, resource limits.
Every layer you remove is one less thing to patch, scan, and defend.
