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
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 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"]
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
execinto a shell that doesn't exist - Read-only filesystem —
docker run --read-onlyprevents writing to the container filesystem - Drop capabilities —
--cap-drop=ALL --cap-add=NET_BIND_SERVICEremoves 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
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 versions —
apt-get install curl=7.88.1-10 - Use lockfiles —
package-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/*
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
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?
Quiz 2: Non-Root Reasoning
Why is running as root inside a container still a security risk, even with namespace isolation?
Quiz 3: Scanning Tools
You want to fail your CI pipeline when a container image has CRITICAL vulnerabilities. Which command achieves this with Trivy?
Hands-On Tasks
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.
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.
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.
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 1001with numeric UIDs - Scan in CI — Trivy/Grype with
--exit-code 1to 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