Small and secure images

Docker · Advanced · 15 min

Every package in an image is attack surface and download time; production images should carry only what runs.

Image size and security improve together: fewer packages means fewer vulnerabilities, smaller downloads and faster scaling.

Choose a minimal base. Roughly from largest to smallest:

  • Full distro images (python:3.13, node:22): convenient, hundreds of MB, many packages.
  • -slim variants: Debian with the extras removed. A good default for interpreted languages.
  • alpine: tiny, but uses musl libc, which can break or slow down prebuilt binaries and Python wheels. Test before switching.
  • Distroless (gcr.io/distroless/...) and hardened minimal images: only the runtime, no shell or package manager.
  • scratch: empty, for static binaries.

Combine with multi-stage builds so compilers never reach the final image.

Don't run as root. Add a user and switch with USER 10001 (numeric IDs work everywhere, including with Kubernetes' runAsNonRoot). Root inside a container is not fully root on the host, but it makes container escapes and mistakes far more damaging.

Keep secrets out. Not in COPY, ARG or ENV; use .dockerignore, BuildKit secret mounts and runtime secrets. Anything in any layer can be extracted, even if a later layer deletes it.

Pin and update. Pin base images to specific versions (or digests) for reproducibility, and rebuild regularly so OS security fixes land. Automate both with Dependabot or Renovate.

Scan and attest. docker scout cves <image> (or Trivy, Grype) lists known CVEs per package. docker buildx build --sbom=true --provenance=true attaches a software bill of materials and build provenance to the image.

At run time, add --read-only, --cap-drop ALL and --security-opt no-new-privileges where the app allows.

Example

# syntax=docker/dockerfile:1
FROM python:3.13-slim AS build
RUN python -m venv /venv
COPY requirements.txt .
RUN /venv/bin/pip install --no-cache-dir -r requirements.txt

FROM python:3.13-slim
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin app
COPY --from=build /venv /venv
COPY --chown=10001:10001 src/ /app/
WORKDIR /app
USER 10001
ENV PATH=/venv/bin:$PATH PYTHONDONTWRITEBYTECODE=1
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]

# docker scout cves api:1.4.2
# docker run --read-only --tmpfs /tmp --cap-drop ALL api:1.4.2

Remember

  • Smaller base = fewer CVEs and faster pulls; test alpine's musl before adopting
  • Run as a numeric non-root USER
  • Secrets in any layer are recoverable: keep them out of the build
  • Pin versions, rebuild often, scan with docker scout or Trivy

Watch out for

  • Switching to alpine for size and then hitting slow or broken musl builds of native Python/Node packages.

Check yourself

  1. Why is a distroless image harder to attack than a full distro image?
  2. A Dockerfile COPYs id_rsa, uses it in a RUN, then deletes it in the next RUN. Is the key in the image?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.docker.com/build/building/best-practices/