← All posts
· 4 min read ·
SecurityDockerContainersDevSecOpsCI/CD

Distroless Containers: Eliminating the OS Attack Surface in 2026

Standard base images ship hundreds of packages that your application never uses - each a potential CVE vector. Distroless images contain only your runtime and application. Here's how to migrate and what to watch out for.

Minimal container architecture diagram

A typical node:20 Docker image ships with bash, curl, wget, apt, coreutils, and hundreds of other packages your Node.js application will never touch. Each of those packages is a CVE surface. When a vulnerability scanner reports 40 HIGH findings on your image, most of them are in this unused software - packages that exist only because the base image is designed for developer convenience, not production security.

Distroless images flip this. They contain your language runtime, standard library, CA certificates, and nothing else. No shell. No package manager. No extra binaries. The attack surface shrinks dramatically.

What “Distroless” Means

Google’s distroless project provides minimal base images for common runtimes. They’re built from Debian packages but assembled to contain only what a runtime needs to execute:

  • gcr.io/distroless/static-debian12 - static binaries only (no libc)
  • gcr.io/distroless/base-debian12 - glibc and OpenSSL
  • gcr.io/distroless/cc-debian12 - C++ runtime libraries
  • gcr.io/distroless/python3-debian12 - Python 3 runtime
  • gcr.io/distroless/nodejs20-debian12 - Node.js 20 runtime
  • gcr.io/distroless/java21-debian12 - Java 21 JRE

Each has a :debug variant that adds busybox sh - useful for debugging but not for production.

Before and After: CVE Count

A concrete example. Scanning a Node.js API image:

Base imageSizeCritical/High CVEs
node:201.1 GB12 Critical, 28 High
node:20-slim280 MB3 Critical, 11 High
node:20-alpine180 MB0 Critical, 2 High
gcr.io/distroless/nodejs20-debian12170 MB0 Critical, 0 High

Alpine is close, but its musl libc has compatibility edge cases. Distroless gives you Debian’s libc compatibility (better for most production workloads) with a comparable attack surface to Alpine.

Multi-Stage Dockerfile

The pattern for distroless is always multi-stage: build in a full image, copy the output into distroless.

Node.js:

# Stage 1: Build
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Stage 2: Runtime  -  distroless
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json .

USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]

Python:

FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target /app/lib -r requirements.txt
COPY . .

FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --from=build /app /app
ENV PYTHONPATH=/app/lib

USER nonroot
CMD ["server.py"]

Go (static binary - smallest possible):

FROM golang:1.22 AS build
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o server .

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app/server /server
USER nonroot
ENTRYPOINT ["/server"]

Go’s static binary in distroless/static is the gold standard - the image contains literally your binary plus CA certs. Total image size: typically under 20 MB.

Running as Non-Root

All distroless images have a nonroot tag and a nonroot user (UID 65532). Use it. A shell-less container running as root is better than a shell-ful container running as non-root, but shell-less + non-root is the correct target.

FROM gcr.io/distroless/nodejs20-debian12:nonroot
# The nonroot tag sets USER nonroot automatically

Or use the explicit UID for clarity in pod security contexts:

# Kubernetes pod spec
securityContext:
    runAsNonRoot: true
    runAsUser: 65532
    runAsGroup: 65532
    readOnlyRootFilesystem: true

Debugging Without a Shell

No shell means no docker exec -it container bash. This catches people off guard the first time. Your options:

Ephemeral debug containers (Kubernetes 1.25+):

kubectl debug -it pod/my-pod \
    --image=busybox \
    --target=my-container \
    -- sh

Distroless debug variant (temporary):

# Use the :debug tag for a one-off investigation, then revert
docker run --rm -it gcr.io/distroless/nodejs20-debian12:debug sh

Structured logging: The best debugging approach is not needing a shell. Emit structured JSON logs, ship them to CloudWatch or Loki, and query them. You should never need to exec into a production container.

Image Signing and Provenance

Distroless images are signed with Sigstore’s Cosign. Verify before pulling in production:

cosign verify gcr.io/distroless/nodejs20-debian12:latest \
    --certificate-identity keyless@distroless.iam.gserviceaccount.com \
    --certificate-oidc-issuer https://accounts.google.com

Add this to your CI pipeline as a verification step before building on top of the image.

The Trade-Off

Distroless isn’t free. You lose:

  • Shell access for debugging (addressed above with ephemeral containers)
  • OS-level package management (not a runtime concern anyway)
  • Some language-specific utilities that assume a full system (rare)

What you gain is a dramatically reduced CVE burden and a container where an attacker with code execution has no obvious tools to work with - no curl for exfiltrating data, no bash for running scripts, no package manager for installing payloads. Lateral movement from a compromised distroless container is significantly harder.

For production images, the trade-off is worth it in almost every case.

← All posts