Container Image Best Practices
Reduce container attack surface with multi-stage builds, distroless base images, non-root users, and automated CVE scanning using Trivy, Scout, and Grype.
Before you start
- ▸Docker Engine 24+ or Docker Desktop installed and running
- ▸Basic familiarity with writing Dockerfiles
- ▸Internet access to pull base images and scanner databases
Bloated, root-running container images full of stale packages are one of the most common security and operational problems in production environments. This guide covers the practices that actually matter: multi-stage builds, distroless base images, non-root users, size reduction, and vulnerability scanning — with concrete Dockerfile examples and CLI commands you can use today.
Why Base Image Choice Matters
Every package, shell binary, and library you ship is attack surface. If an attacker escapes your application, a full Debian or Ubuntu base gives them curl, bash, package managers, and thousands of binaries to work with. A distroless or minimal image gives them almost nothing.
- Full distro images (e.g.,
ubuntu:24.04,debian:bookworm): convenient for development, problematic in production. - Slim images (e.g.,
debian:bookworm-slim,python:3.12-slim): remove docs and some locale data — better, but still carry a shell and package manager. - Alpine (
alpine:3.20): ~7 MB, musl libc instead of glibc. Watch for subtle compatibility issues with binaries compiled against glibc. - Distroless (
gcr.io/distroless/static,gcr.io/distroless/base): Google-maintained images with no shell, no package manager, and minimal system libraries. Ideal for compiled binaries and JVM apps. - Scratch: a completely empty image. Only works for fully static binaries with zero external dependencies.
Multi-Stage Builds
Multi-stage builds are the single most effective tool for keeping production images small and clean. You compile and install dependencies in a heavy builder stage, then copy only the final artifact into a minimal runtime image.
Go binary — scratch final stage
# syntax=docker/dockerfile:1
FROM golang:1.22-bookworm AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/server
FROM scratch
COPY --from=builder /app/server /server
# Copy TLS root certs so HTTPS calls work
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65532:65532
ENTRYPOINT ["/server"]
The -ldflags="-s -w" flag strips debug symbols and DWARF info, shrinking the binary significantly. CGO_ENABLED=0 produces a fully static binary that runs in scratch.
Python app — distroless final stage
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /install /usr/local
COPY --from=builder /app /app
WORKDIR /app
USER nonroot
CMD ["main.py"]
Node.js app — production dependencies only
# syntax=docker/dockerfile:1
FROM node:20-bookworm AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:20-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER nonroot
CMD ["dist/index.js"]
Running as Non-Root
By default, processes inside containers run as root (UID 0). If your application is compromised and there is a container escape vulnerability, root-in-container maps to root-on-host unless user namespaces are configured. Always drop to a non-privileged user before the final CMD or ENTRYPOINT.
Creating a dedicated user
FROM debian:bookworm-slim
RUN groupadd --gid 10001 appgroup \
&& useradd --uid 10001 --gid appgroup --no-create-home --shell /sbin/nologin appuser
COPY --chown=appuser:appgroup ./app /app
USER appuser
CMD ["/app/server"]
Distroless images include a built-in nonroot user at UID/GID 65532. Reference it with USER nonroot or USER 65532:65532 — the numeric form works even in scratch-derived images that have no /etc/passwd.
Verify at runtime
docker run --rm your-image whoami
# Expected: appuser (or nonroot)
docker run --rm your-image id
# Expected: uid=10001(appuser) gid=10001(appgroup)
Minimising Image Size
Beyond multi-stage builds and base image choice, several Dockerfile patterns reduce size.
- Combine RUN layers: Each
RUNinstruction creates a layer. Chain commands and clean up caches in the same layer so the cleanup is actually committed. - Use
--no-install-recommends: On Debian/Ubuntu-based images this avoids pulling in suggested packages. - Avoid COPY of build tooling: Let multi-stage handle this. Never copy
node_modules,.git, or build caches into the final image. - Use
.dockerignore: Prevents unnecessary files from entering the build context and accidentally landing in an image layer.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libssl3 \
&& rm -rf /var/lib/apt/lists/*
Sample .dockerignore
.git
.github
*.md
node_modules
dist
__pycache__
*.pyc
.env*
tests/
docs/
Measure image size
docker images your-image --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
# Output will vary; aim for single-digit MB for compiled binaries
Vulnerability Scanning
Building a minimal image reduces your attack surface but does not eliminate CVEs from the packages that remain. Scan early and scan often — ideally in CI before pushing to a registry.
Trivy (recommended — fast, accurate, open source)
# Install on Debian/Ubuntu
sudo apt-get install -y wget apt-transport-https gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo gpg --dearmor -o /usr/share/keyrings/trivy.gpg
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install -y trivy
# Install on Fedora/RHEL
sudo rpm -ivh https://github.com/aquasecurity/trivy/releases/latest/download/trivy_Linux-64bit.rpm
# Install on Arch
yay -S trivy
# Scan a local image; exit non-zero if HIGH or CRITICAL CVEs found
trivy image --severity HIGH,CRITICAL --exit-code 1 your-image:latest
# Scan and output SARIF for GitHub Advanced Security
trivy image --format sarif --output trivy-results.sarif your-image:latest
Docker Scout (built into Docker Desktop and CLI)
# Requires docker login
docker scout cves your-image:latest
docker scout recommendations your-image:latest
Grype (Anchore, fast alternative)
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
grype your-image:latest --fail-on high
Pinning and Reproducibility
Always pin base images by digest in production pipelines, not just by tag. Tags are mutable; digests are not.
# Get the digest
docker inspect --format='{{index .RepoDigests 0}}' gcr.io/distroless/static
# Use it in FROM
FROM gcr.io/distroless/static@sha256:3d0f463de06b7ddff27684ec3bfd0b54a425149d0f8685308b1fdf297b0265e6
Combine this with a tool like Renovate or Dependabot to receive automated PRs when a newer digest is available.
Verifying the Final Image
# List all layers and their sizes
docker history your-image:latest
# Inspect running user and environment
docker inspect your-image:latest | jq '.[0].Config | {User, Entrypoint, Cmd, Env}'
# Confirm no shell in distroless image
docker run --rm your-image:latest sh
# Expected: container exits immediately with a non-zero status (no shell found)
Troubleshooting
- Application crashes in distroless but works in slim: You likely have a runtime dependency on a shared library. Run
ldd /your/binaryin the builder stage and copy missing.sofiles explicitly, or usegcr.io/distroless/basewhich includes glibc. - Permission denied on startup: The non-root user cannot write to paths owned by root. Fix with
COPY --chownorRUN chownbefore theUSERinstruction. - Trivy reports CVEs in your distroless image: Distroless images are still updated regularly. Pull the latest digest and re-scan; many CVEs in distroless base packages are not exploitable without a shell but your security policy may still require action.
- Alpine musl compatibility issues: If a pip wheel or npm native addon fails, switch to a glibc-based image for the build stage and copy only compiled output, or use the
-slimDebian variant throughout.
Frequently asked questions
- Can I debug a distroless container when something goes wrong in production?
- Yes. Use `docker debug` (Docker Desktop 4.27+) or add an ephemeral debug sidecar with `kubectl debug` in Kubernetes, which injects a shell-bearing container into the pod without modifying the original image.
- Is Alpine always a good choice for minimising image size?
- Alpine is small, but its musl libc can cause subtle compatibility issues with software compiled against glibc, particularly Python C extensions and some Node.js native addons. Test thoroughly before committing to Alpine in production.
- How often should I rebuild and rescan images?
- Rebuild and rescan at minimum weekly, even if your application code has not changed. Base image packages receive CVE fixes constantly, and your pinned digest will become stale.
- Does running as non-root actually matter if Kubernetes has its own RBAC?
- Yes. Kubernetes RBAC controls API access, not what a compromised process can do inside a node. A root container combined with a container escape vulnerability (e.g., a kernel CVE) gives an attacker host root access. Non-root is a separate, necessary layer of defence.
- What is the difference between gcr.io/distroless/static and gcr.io/distroless/base?
- The static image contains only CA certificates and timezone data — it requires a fully static binary with zero shared library dependencies. The base image adds glibc, libssl, and openssl, making it suitable for dynamically linked binaries.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.