← All articles

Distroless and Minimal Docker Images: Reducing Attack Surface

January 5, 2026 · 5 min read

dockersecuritydevops

A standard Ubuntu-based Docker image contains hundreds of packages: shells, package managers, system utilities, cron, and libraries your application never uses. Each of these is a potential vulnerability. Distroless images and scratch-based builds strip the image down to the absolute minimum — just your application and its runtime dependencies.

What Distroless Means

Google's distroless images contain:

  • The language runtime (JVM, Python interpreter, libc)
  • CA certificates
  • Your application

They do NOT contain:

  • A shell (/bin/sh, /bin/bash)
  • Package managers (apt, apk, pip)
  • System utilities (curl, wget, ls)

Without a shell, many common attack vectors are closed — an attacker who gains code execution can't drop to an interactive shell.

Distroless for Node.js

# syntax=docker/dockerfile:1
 
# Build stage: use the full node image
FROM node:20-alpine AS builder
WORKDIR /app
 
COPY package*.json ./
RUN npm ci --only=production
 
COPY . .
RUN npm run build
 
# Production stage: distroless Node.js runtime
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
 
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
 
EXPOSE 3000
CMD ["dist/server.js"]

Note: CMD doesn't use ["node", "dist/server.js"] — the entrypoint is already node in the distroless image.

Image size comparison:

docker images | grep myapp
# myapp:node-alpine      167MB
# myapp:distroless       115MB

Distroless for Python

# syntax=docker/dockerfile:1
 
FROM python:3.12-slim AS builder
WORKDIR /app
 
COPY requirements.txt .
RUN pip install --no-cache-dir --target /app/deps -r requirements.txt
 
COPY . .
 
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
 
COPY --from=builder /app/deps /app/deps
COPY --from=builder /app/src ./src
 
ENV PYTHONPATH=/app/deps
CMD ["src/main.py"]

Distroless for Java

FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
 
COPY pom.xml .
RUN mvn dependency:go-offline -q
 
COPY src ./src
RUN mvn package -DskipTests -q
 
# Use jlink to create a minimal JRE
RUN jlink \
    --add-modules java.base,java.net.http,java.sql \
    --strip-debug \
    --no-man-pages \
    --no-header-files \
    --compress=2 \
    --output /custom-jre
 
FROM gcr.io/distroless/base-debian12
WORKDIR /app
 
COPY --from=builder /custom-jre /custom-jre
COPY --from=builder /app/target/myapp.jar .
 
ENTRYPOINT ["/custom-jre/bin/java", "-jar", "myapp.jar"]

jlink creates a minimal JRE containing only the modules your application uses — a typical Spring Boot app can be reduced from a 400MB JDK to a 50-70MB custom JRE.

Scratch for Go Binaries

Go compiles to a static binary with zero runtime dependencies. Use scratch — the most minimal possible base:

# syntax=docker/dockerfile:1
 
FROM golang:1.23-alpine AS builder
WORKDIR /app
 
COPY go.mod go.sum ./
RUN go mod download
 
COPY . .
 
# Build a fully static binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-w -s" -o server ./cmd/server
 
# The final image is just the binary
FROM scratch
COPY --from=builder /app/server /server
 
# Copy CA certs for HTTPS calls
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
 
# Copy timezone data if needed
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
 
EXPOSE 8080
ENTRYPOINT ["/server"]

Image size:

docker images myapp
# myapp:golang-alpine    350MB
# myapp:distroless       25MB
# myapp:scratch          8MB

Debugging Distroless Containers

The biggest operational challenge with distroless is debugging — no shell means no exec sh for interactive inspection. Two approaches:

Debug variant images:

Every distroless image has a :debug tag that adds BusyBox shell utilities:

# Use debug image for debugging sessions
docker run --rm -it --entrypoint sh gcr.io/distroless/nodejs20-debian12:debug

Switch to the debug image temporarily in your Dockerfile:

# In production
FROM gcr.io/distroless/nodejs20-debian12
 
# For debugging (change this line, rebuild, debug, revert)
FROM gcr.io/distroless/nodejs20-debian12:debug

Ephemeral debug containers (Kubernetes):

kubectl debug -it my-pod --image=busybox --target=my-container

This adds a debug container to a running pod without restarting it.

Copy tools into the image temporarily:

FROM gcr.io/distroless/base-debian12 AS production
 
FROM production AS debug
COPY --from=busybox:latest /bin/sh /bin/sh
COPY --from=busybox:latest /bin/ls /bin/ls
COPY --from=busybox:latest /bin/wget /bin/wget

Then use --build-target=debug during debugging sessions.

Scanning Distroless Images

Despite having fewer packages, distroless images still have dependencies that can contain CVEs. Always scan them:

trivy image gcr.io/distroless/nodejs20-debian12

Update your distroless base images regularly — Google publishes frequent updates as upstream packages are patched.

In CI, pin to digest rather than tag for reproducibility:

FROM gcr.io/distroless/nodejs20-debian12@sha256:exact-hash-here
# Get the current digest
docker pull gcr.io/distroless/nodejs20-debian12
docker inspect gcr.io/distroless/nodejs20-debian12 --format='{{index .RepoDigests 0}}'

Pin the digest in your Dockerfile and update it on a schedule (Dependabot or Renovate can automate this).

Chainguard Images

Chainguard offers distroless-style images that are:

  • Rebuilt daily with latest patches
  • Signed with Sigstore (provenance and attestation)
  • Zero known CVEs at time of build
FROM cgr.dev/chainguard/node:latest AS builder
# ... build steps
 
FROM cgr.dev/chainguard/node:latest-dev AS dev
# dev variant has shell for debugging
 
FROM cgr.dev/chainguard/node:latest
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["dist/server.js"]

Chainguard's free tier covers common runtimes. For production use, their paid offering includes SLAs on vulnerability patching time.

When to Use Distroless

Use distroless when:

  • Security compliance requires minimal attack surface
  • You need to pass vulnerability scans for deployment
  • You're running public-facing services
  • Image size matters (mobile edge deployments, large-scale pulls)

Stick with standard images when:

  • You're actively developing and frequently need to debug in the container
  • Your application requires system utilities at runtime (unlikely but possible)
  • You're using languages with complex runtime dependencies that don't fit the distroless model

The migration to distroless is almost always worth the one-time effort. Smaller images, fewer CVEs, faster pulls, and better security posture — with the only tradeoff being a slightly more complex debug workflow.