Distroless and Minimal Docker Images: Reducing Attack Surface
January 5, 2026 · 5 min read
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 115MBDistroless 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 8MBDebugging 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:debugSwitch 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:debugEphemeral debug containers (Kubernetes):
kubectl debug -it my-pod --image=busybox --target=my-containerThis 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/wgetThen 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-debian12Update 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.