QA Engineer Skills 2026QA-2026Minimal Container Images

Minimal Container Images

The Principle: Every Unnecessary Package Is an Attack Surface

Every binary, library, and shell in a container image is a potential vulnerability waiting to be discovered. A full Ubuntu base image ships with hundreds of packages your application never uses -- but an attacker can exploit any of them. Minimal images reduce your attack surface, shrink download times, and cut storage costs.

The goal is simple: your container should contain your application and its runtime dependencies, nothing else.


Image Size and Vulnerability Comparison

Base Image Compressed Size Packages CVEs (typical) Use Case
node:20 ~350 MB 400+ 50-200 Development only
node:20-slim ~80 MB ~100 10-30 Build stages
node:20-alpine ~50 MB ~30 5-15 Production (if musl-compatible)
distroless/nodejs20 ~40 MB ~10 0-5 Production (recommended)
scratch + static binary <10 MB 0 0 Go/Rust applications

The difference between 200 CVEs and 0 CVEs is not about better patching -- it is about having fewer things to patch. Distroless images achieve near-zero vulnerability counts by removing everything except the language runtime.


Multi-Stage Builds: The Foundation

Multi-stage builds separate the build environment from the runtime environment. Your build stage can have compilers, package managers, and development tools. Your runtime stage has only the compiled output.

Bad: Single-Stage Build

# BAD: 900MB+ image with shell, package managers, compilers
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

# This image contains:
# - npm (can be used to install malware)
# - apt-get (can install arbitrary packages)
# - bash, sh (can run arbitrary commands)
# - gcc, make (compilation tools)
# - wget, curl (can download payloads)
# - /etc/passwd, /etc/shadow (user database)

Good: Multi-Stage Build

# GOOD: Multi-stage build, ~150MB distroless image
# Stage 1: Build
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Stage 2: Runtime
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"]
# No shell, no package manager, no curl -- minimal attack surface

Python Multi-Stage Build

# Stage 1: Build with pip and compilation tools
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt -o requirements.txt
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
COPY . .

# Stage 2: Runtime with only the installed packages
FROM python:3.12-slim
WORKDIR /app
# Copy only the installed packages from the builder
COPY --from=builder /install /usr/local
COPY --from=builder /app .

# Security hardening
RUN useradd --create-home --shell /bin/false appuser
USER appuser

CMD ["python", "-m", "app.main"]

Go Application: scratch Image

# Go produces static binaries -- no runtime needed
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build a statically linked binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server .

# The scratch image is literally empty -- 0 bytes
FROM scratch
# Copy CA certificates for HTTPS calls
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# Total image size: ~15MB (just the Go binary + certs)

Distroless Images: The Sweet Spot

Google's distroless images contain only the language runtime and your application. No shell, no package manager, no OS utilities. This makes them ideal for production because even if an attacker gets code execution, they have no tools to work with.

Available Distroless Images

Image Contents Use Case
gcr.io/distroless/static-debian12 CA certs, tzdata Static binaries (Go, Rust)
gcr.io/distroless/base-debian12 glibc, libssl, CA certs C/C++ applications
gcr.io/distroless/cc-debian12 glibc, libgcc, libstdc++ C++ applications
gcr.io/distroless/nodejs20-debian12 Node.js 20 runtime Node.js applications
gcr.io/distroless/python3-debian12 Python 3 runtime Python applications
gcr.io/distroless/java21-debian12 OpenJDK 21 Java applications

Debugging Distroless Containers

The lack of shell makes debugging harder. Use debug variants in non-production:

# Debug variant includes busybox shell
# Use ONLY in development/staging, never production
docker run -it gcr.io/distroless/nodejs20-debian12:debug sh

# For production debugging, use ephemeral containers in K8s
kubectl debug -it myapp-pod --image=busybox --target=myapp-container

Alpine Images: Smaller but with Tradeoffs

Alpine Linux uses musl libc instead of glibc, which causes compatibility issues with some applications. Understand the tradeoffs before choosing Alpine.

When Alpine Works Well

  • Pure JavaScript/TypeScript Node.js applications (no native addons)
  • Go applications compiled with CGO_ENABLED=0
  • Simple Python applications without C extensions
  • Utility containers (curl, wget, debugging tools)

When Alpine Causes Problems

  • Python packages with C extensions (numpy, pandas, scipy) -- compilation is slow and sometimes fails
  • Node.js packages with native addons (bcrypt, sharp) -- need alpine-specific builds
  • Applications that depend on glibc-specific behavior (DNS resolution edge cases)
  • Java applications (use Eclipse Temurin JDK images instead)
# Alpine with native Node.js addons requires extra build tools
FROM node:20-alpine AS builder
# Need to install build tools for native modules
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]

Security Hardening Checklist

Beyond choosing a minimal base image, apply these hardening practices:

Run as Non-Root

# Create a non-root user
RUN useradd --create-home --shell /bin/false --uid 1001 appuser
USER appuser
# or for Alpine:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

Read-Only Filesystem

# Kubernetes: mount filesystem as read-only
securityContext:
  readOnlyRootFilesystem: true
# If the app needs to write temp files:
volumeMounts:
  - name: tmp
    mountPath: /tmp
volumes:
  - name: tmp
    emptyDir: {}

Drop All Capabilities

securityContext:
  capabilities:
    drop: ["ALL"]
  # Add back only what you need:
  # add: ["NET_BIND_SERVICE"]  # If binding to port < 1024

Pin Image Digests

# BAD: tags can be overwritten
FROM node:20-slim

# GOOD: digests are immutable
FROM node:20-slim@sha256:abc123def456...

# Best practice: use a .env or CI variable for the digest
ARG NODE_IMAGE_DIGEST=sha256:abc123def456...
FROM node:20-slim@${NODE_IMAGE_DIGEST}

Testing Minimal Images

Verify your minimal images meet security requirements:

# Test 1: No shell available
docker run --rm myapp:latest /bin/sh -c "echo pwned" 2>&1 | grep -q "not found"
echo "PASS: No shell in image"

# Test 2: Running as non-root
USER_ID=$(docker run --rm myapp:latest id -u 2>/dev/null || echo "no id command")
if [ "$USER_ID" != "0" ]; then
    echo "PASS: Not running as root (UID: $USER_ID)"
fi

# Test 3: Image size under threshold
SIZE=$(docker image inspect myapp:latest --format '{{.Size}}')
MAX_SIZE=$((200 * 1024 * 1024))  # 200MB
if [ "$SIZE" -lt "$MAX_SIZE" ]; then
    echo "PASS: Image size $(($SIZE / 1024 / 1024))MB is under 200MB"
fi

# Test 4: Scan for vulnerabilities
trivy image --exit-code 1 --severity CRITICAL myapp:latest
echo "PASS: No critical vulnerabilities"

Automated Image Compliance in CI

# .github/workflows/image-compliance.yml
- name: Check image size
  run: |
    SIZE=$(docker image inspect myapp:${{ github.sha }} --format '{{.Size}}')
    MAX=$((200 * 1024 * 1024))
    if [ "$SIZE" -gt "$MAX" ]; then
      echo "Image size $(($SIZE / 1024 / 1024))MB exceeds 200MB limit"
      exit 1
    fi

- name: Check non-root user
  run: |
    docker run --rm myapp:${{ github.sha }} whoami | grep -v root

The investment in minimal images pays dividends across security (fewer CVEs), performance (faster pulls), and cost (less storage). Make it a default, not an optimization.