$linuxjunkies
>

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.

IntermediateUbuntuDebianFedoraArch10 min readUpdated June 7, 2026

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 RUN instruction 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.

# 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/binary in the builder stage and copy missing .so files explicitly, or use gcr.io/distroless/base which includes glibc.
  • Permission denied on startup: The non-root user cannot write to paths owned by root. Fix with COPY --chown or RUN chown before the USER instruction.
  • 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 -slim Debian variant throughout.
tested on:Ubuntu 24.04Debian 12Fedora 40Arch rolling

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