Development images are bloated, root-running, unscanned playgrounds. Production images must be minimal, locked-down, signed, and reproducible. This lesson shows you how to build images that belong in production.

1. The Production Mindset

A development image optimises for convenience: editors, debuggers, build tools, running as root. A production image optimises for security and reliability. The rules are different:

  • Minimal surface area — every binary you ship is a potential exploit vector
  • No build tools — compilers, package managers, and headers stay in the build stage
  • No secrets — API keys, credentials, and tokens never baked into the image
  • Non-root user — the container process runs with least privilege
  • Deterministic builds — the same commit always produces the same image
Dev ≠ Prod

If your production Dockerfile starts with FROM node:latest and runs as root with no health checks, you don't have a production image — you have a development image deployed to production. That distinction matters when an attacker gets in.

2. Base Image Selection

Your base image choice determines 80% of your image's size, vulnerability count, and attack surface. Here are your options:

Base Image Size Shell Pkg Manager Use Case
Full (debian, ubuntu) ~120 MB ✅ bash ✅ apt Debugging, legacy apps needing many system libs
Slim (debian-slim) ~80 MB ✅ bash ✅ apt Good default when you need some system packages
Alpine ~5 MB ✅ sh ✅ apk Small images; watch for musl libc compatibility
Distroless (Google) ~15–25 MB Production apps (Java, Python, Node, Go, Rust)
Scratch 0 MB Statically-compiled binaries (Go, Rust)
Alpine Gotcha: musl vs glibc

Alpine uses musl libc instead of glibc. Most software works fine, but some compiled binaries or Python packages with C extensions may segfault or fail to load. Always test thoroughly when switching to Alpine.

3. Running as Non-Root

By default, the process inside a container runs as root (UID 0). "But it's in a container!" you say. Here's why that still matters:

  • Kernel exploits — a container escape exploit plus root access = full host compromise
  • Volume mounts — files written to bind mounts are owned by root on the host
  • Principle of least privilege — your web server doesn't need to install packages

The fix is simple — add a USER instruction:

# Create a non-root user
RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 --ingroup appgroup appuser

# Set ownership of app files
COPY --chown=1001:1001 ./app /app

# Switch to non-root
USER 1001

CMD ["/app/server"]
Use Numeric UIDs

Always use numeric UIDs (USER 1001) rather than names (USER appuser). Numeric UIDs work even in distroless/scratch images where /etc/passwd doesn't exist, and they're unambiguous across environments.

4. Security Scanning

Your image contains an OS distribution and application dependencies — both can have known vulnerabilities (CVEs). Scanning tools check every installed package against vulnerability databases.

Popular scanners:

  • Trivy (Aqua Security) — fast, open-source, broad coverage
  • Grype (Anchore) — fast, open-source, good SBOM integration
  • Docker Scout — built into Docker Desktop, policy-based
  • Snyk — SaaS, great IDE integration, auto-fix suggestions
# Scan with Trivy
$ trivy image myapp:latest

myapp:latest (debian 12.4)
============================
Total: 23 (UNKNOWN: 0, LOW: 12, MEDIUM: 8, HIGH: 2, CRITICAL: 1)

┌──────────────┬──────────────────┬──────────┬─────────────────────┐
│   Library    │  Vulnerability   │ Severity │   Fixed Version     │
├──────────────┼──────────────────┼──────────┼─────────────────────┤
│ libssl3      │ CVE-2024-0727    │ CRITICAL │ 3.0.13-1~deb12u1    │
│ libcurl4     │ CVE-2024-2398    │ HIGH     │ 7.88.1-10+deb12u5   │
│ zlib1g       │ CVE-2023-45853   │ HIGH     │ 1:1.2.13.dfsg-1+d.. │
└──────────────┴──────────────────┴──────────┴─────────────────────┘

CI integration — fail the pipeline if critical vulnerabilities are found:

# In GitHub Actions:
- name: Scan image
  run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}

5. Reducing Attack Surface

Every component you remove from your image is one less thing an attacker can exploit if they gain access:

  • No shell — use distroless; an attacker can't exec into a shell that doesn't exist
  • Read-only filesystemdocker run --read-only prevents writing to the container filesystem
  • Drop capabilities--cap-drop=ALL --cap-add=NET_BIND_SERVICE removes unneeded kernel capabilities
  • Minimal packages — no curl, no wget, no package manager in the final image
# Runtime hardening flags
docker run \
  --read-only \
  --tmpfs /tmp \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --security-opt=no-new-privileges \
  myapp:prod
Each Removal = One Less Exploit

No curl in the image? The attacker can't download additional tools. No shell? They can't run arbitrary commands. No package manager? They can't install anything. Stack these restrictions.

6. Reproducible Builds

If you can't reproduce your build, you can't audit it, roll it back, or trust it. Reproducibility means the same inputs always yield the same image.

  • Pin base image by digest — tags are mutable; digests are not
  • Pin package versionsapt-get install curl=7.88.1-10
  • Use lockfilespackage-lock.json, Gemfile.lock, go.sum
  • Never use latest — it means something different tomorrow
# ❌ Non-reproducible
FROM node:20
RUN apt-get update && apt-get install -y curl

# ✅ Reproducible
FROM node:20.11.1-bookworm-slim@sha256:abc123...
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl=7.88.1-10+deb12u5 \
 && rm -rf /var/lib/apt/lists/*
Why Digests Matter

The tag node:20 pointed to one image yesterday and a different one today (after a security patch was published). Your build broke and you don't know why. A digest (@sha256:...) is immutable — it always refers to the exact same image layers.

7. Image Signing & Verification

Scanning tells you an image is safe now. Signing tells you it hasn't been tampered with since. You need both.

  • Cosign (Sigstore) — keyless signing via OIDC, stored in transparency log
  • Docker Content Trust (Notary) — built into Docker, uses TUF framework
# Sign with Cosign (keyless, uses GitHub OIDC in CI)
$ cosign sign --yes ghcr.io/myorg/myapp@sha256:abc123...

# Verify before deployment
$ cosign verify \
    --certificate-identity=https://github.com/myorg/myapp/.github/workflows/build.yml@refs/heads/main \
    --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
    ghcr.io/myorg/myapp@sha256:abc123...

In your Kubernetes cluster, use an admission controller (e.g., Kyverno, OPA Gatekeeper) to reject unsigned images at deploy time.

Production Image Checklist

1. Base Image Selection Minimal base · Pinned digest · No unnecessary packages 2. Non-Root User USER 1001 · Correct file ownership · No privilege escalation 3. Security Scanning Trivy/Grype in CI · Fail on CRITICAL · Generate SBOM 4. Image Signing Cosign · Keyless OIDC · Verify at admission 5. Runtime Hardening Read-only FS · Drop caps · No shell · no-new-privileges Layered Security

Example Production Dockerfile

# syntax=docker/dockerfile:1

# ── Build Stage ──────────────────────────────────────────────────
FROM node:20.11.1-bookworm-slim@sha256:a1b2c3d4... AS build

WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
RUN npm run build

# ── Production Stage ─────────────────────────────────────────────
FROM gcr.io/distroless/nodejs20-debian12@sha256:e5f6a7b8...

LABEL org.opencontainers.image.source="https://github.com/myorg/myapp"
LABEL org.opencontainers.image.version="1.4.2"

WORKDIR /app
COPY --from=build --chown=1001:1001 /build/dist ./dist
COPY --from=build --chown=1001:1001 /build/node_modules ./node_modules

USER 1001

EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
  CMD ["/nodejs/bin/node", "-e", "require('http').get('http://localhost:8080/health')"]

ENTRYPOINT ["/nodejs/bin/node", "dist/server.js"]

Key features: multi-stage (build tools never reach production), pinned by digest, distroless base (no shell), non-root user, health check, OCI labels.

Interactive Quizzes

Quiz 1: Base Image Selection

You're deploying a statically-compiled Go binary with no external dependencies. Which base image gives the smallest attack surface?

  • Alpine — it's only 5 MB
  • Distroless — no shell or package manager
  • Scratch — literally nothing except your binary
  • Debian Slim — good balance of size and compatibility

Quiz 2: Non-Root Reasoning

Why is running as root inside a container still a security risk, even with namespace isolation?

  • Root in a container can directly access the host filesystem
  • A kernel vulnerability could allow a container escape, and root makes that escape more powerful
  • Containers don't actually provide any isolation from root
  • Running as root makes the container use more memory

Quiz 3: Scanning Tools

You want to fail your CI pipeline when a container image has CRITICAL vulnerabilities. Which command achieves this with Trivy?

  • trivy image --severity CRITICAL myapp:v1
  • trivy image --exit-code 1 --severity CRITICAL myapp:v1
  • trivy image --fail-on-vuln myapp:v1
  • trivy scan --block CRITICAL myapp:v1

Hands-On Tasks

🛠️ Task 1: Compare Base Images

Build the same simple Go application with different base images and compare size + vulnerability count:

# Create a simple Go app (main.go):
# package main
# import "fmt"
# func main() { fmt.Println("Hello, production!") }

# Build with different bases:
docker build -t myapp:full    --target full .
docker build -t myapp:alpine  --target alpine .
docker build -t myapp:scratch --target scratch .

# Compare sizes:
docker images myapp

# Scan each:
trivy image myapp:full
trivy image myapp:alpine
trivy image myapp:scratch

Expected result: scratch has 0 vulnerabilities (there's nothing to be vulnerable). Full debian may have 50+. Alpine will be somewhere in between.

🛠️ Task 2: Scan and Fix

Run Trivy on a vulnerable image and fix the top vulnerability:

# Pull an intentionally outdated image:
docker pull node:18.0.0

# Scan it:
trivy image node:18.0.0

# Find the most critical vulnerability.
# Check the "Fixed Version" column.
# Create a Dockerfile that uses the fixed base:
FROM node:18.19.1-slim
# ... rest of your app

# Rebuild and re-scan to confirm the fix:
trivy image myapp:fixed

Goal: Reduce CRITICAL count to zero by updating the base image version.

🌍 Not Just Docker: Chainguard Images

Chainguard provides hardened container images similar to Google's distroless, but with a key advantage: they're rebuilt daily with the latest security patches. While distroless images may lag behind on updates, Chainguard images aim for zero known CVEs at all times. They offer drop-in replacements for common bases: cgr.dev/chainguard/node, cgr.dev/chainguard/python, cgr.dev/chainguard/go.

🏢 Industry Practice: Image Policies

Large organisations enforce production image standards at scale:

  • Approved base images only — a curated registry of vetted bases; all others rejected at admission
  • Signature required — only images signed in CI can be deployed (unsigned = blocked)
  • Vulnerability threshold — images with CRITICAL or HIGH CVEs cannot deploy until fixed
  • Max age policy — images older than 30 days must be rebuilt (forces security patches)

Tools like Kyverno, OPA Gatekeeper, and Docker Scout Policies enforce these rules automatically at deploy time.

Key Takeaways

  • Choose the smallest viable base — distroless for most apps, scratch for static binaries
  • Never run as root — use USER 1001 with numeric UIDs
  • Scan in CI — Trivy/Grype with --exit-code 1 to block vulnerable images
  • Pin everything — base images by digest, packages by version, dependencies by lockfile
  • Sign your images — Cosign + admission controllers = supply chain integrity
  • Harden at runtime — read-only FS, dropped capabilities, no-new-privileges
  • Every removal is a security gain — no shell, no curl, no package manager in prod