Why Are Ordinary Docker Images a Security Risk?
When first learning Docker, most people reach for ubuntu:22.04 or debian:bookworm as a base image. It’s convenient, has all the tools you need, and apt install works freely. But after running things in production for a while, I started to realize this habit deserved a second look.
Try running this command to see what a “normal” Ubuntu image actually contains:
docker run --rm ubuntu:22.04 ls /bin
The result: hundreds of binaries — bash, sh, curl, wget, nc, python3… None of them are related to your application. But if the container is compromised, an attacker has everything they need to perform lateral movement, exfiltrate data, or set up a reverse shell right inside the container.
Google created Distroless Images to solve exactly this problem.
What Are Distroless Images?
Distroless is a set of base images maintained by Google that contain exactly what the runtime needs — nothing more, nothing less. No shell (/bin/sh), no package manager, no coreutils. Only:
- The standard C library (
glibcormusl) - CA certificates (for HTTPS)
- The corresponding runtime (JRE, Python interpreter, Node.js…)
- Timezone data
From a security standpoint, this means: even if an RCE vulnerability is exploited, an attacker who gains entry to the container can’t execute any commands because there’s no shell. Without a package manager, they can’t download additional tools either. The attack surface is dramatically reduced compared to a standard Ubuntu image. If you’re also concerned about privilege escalation at the daemon level, Docker Rootless mode is another layer worth stacking on top.
On a production cluster running 30+ containers, I applied this approach and cut resource usage by 40% — mostly from smaller images, more efficient layer caching, and noticeably faster image pull times when scaling out.
Setup and Environment Preparation
No additional installation required. Distroless images are hosted directly on Google Container Registry and GitHub Container Registry — if you have Docker, you’re ready to go.
A list of commonly used images:
# Common Distroless images
gcr.io/distroless/static-debian12 # CA certs + timezone only (for Go static binaries)
gcr.io/distroless/base-debian12 # glibc + OpenSSL (for C/C++, Rust)
gcr.io/distroless/java21-debian12 # JRE 21
gcr.io/distroless/python3-debian12 # Python 3
gcr.io/distroless/nodejs20-debian12 # Node.js 20
Each image also has a :debug tag — which adds a busybox shell for debugging when needed, but never use :debug in production.
Detailed Configuration: Multi-Stage Build with Distroless
The key to using Distroless is combining it with multi-stage builds: use a full image in the first stage to build, then copy the artifact into Distroless in the final stage — clean, with no changes needed to your source code.
Example 1: Go Application (Static Binary)
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp ./cmd/server
# Stage 2: Runtime — use Distroless static
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/myapp /myapp
EXPOSE 8080
ENTRYPOINT ["/myapp"]
The final image is only ~3–5MB — 160 times smaller than golang:1.22 (~800MB), and even smaller than alpine (~10MB) since there’s no shell pulled in.
Example 2: Python Application (FastAPI/Flask)
# Stage 1: Install dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt
# Stage 2: Runtime
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /install /usr/local
COPY --from=builder /app /app
WORKDIR /app
COPY . .
EXPOSE 8000
ENTRYPOINT ["python3", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Example 3: Java Spring Boot
If you’ve previously tried to slim down a Spring Boot image from 800MB, Distroless takes that even further by stripping the runtime stage down to just the JRE:
# Stage 1: Build JAR
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
# Stage 2: Runtime with minimal JRE
FROM gcr.io/distroless/java21-debian12
COPY --from=builder /app/target/myapp.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]
Important Configuration Notes
- ENTRYPOINT must use exec form
["binary", "arg"], not shell formbinary arg— because there’s no shell to parse it. - Do not use
CMDas a string — same reason as above. - No user management — Distroless includes a built-in
nonrootuser (UID 65532). AddUSER nonrootbeforeENTRYPOINTto avoid running as root. - No writable
/tmpby default — if your app needs temp files, mount a tmpfs or use an emptyDir in Kubernetes.
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/myapp /myapp
# Run as nonroot instead of root
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/myapp"]
Testing and Monitoring
Comparing Image Sizes
# Build and compare
docker build -t myapp:distroless -f Dockerfile.distroless .
docker build -t myapp:ubuntu -f Dockerfile.ubuntu .
docker images | grep myapp
# myapp distroless a1b2c3d4 2 minutes ago 8.2MB
# myapp ubuntu e5f6g7h8 3 minutes ago 182MB
Verifying No Shell Is Present
# Try exec into the container — it will fail
docker run -d --name test myapp:distroless
docker exec -it test /bin/sh
# Error: OCI runtime exec failed: exec failed: unable to start container process:
# exec: "/bin/sh": stat /bin/sh: no such file or directory
# Use the debug image for troubleshooting when needed
docker run -it gcr.io/distroless/static-debian12:debug /busybox/sh
Vulnerability Scanning with Trivy
Comparing CVE counts is the fastest way to see the difference:
# Install Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
# Scan the ubuntu-based image
trivy image myapp:ubuntu
# Result: ~180 vulnerabilities (HIGH: 12, CRITICAL: 3)
# Scan the distroless image
trivy image myapp:distroless
# Result: ~8 vulnerabilities (HIGH: 1, CRITICAL: 0)
That gap is pretty striking — from 3 CRITICAL down to 0, and the total CVE count drops by more than 20x. This is the most compelling real-world reason to consider moving to Distroless in production. For an additional layer of Dockerfile hygiene that catches misconfigurations before they reach the registry, Hadolint can lint your Dockerfile automatically as part of CI.
Integrating Healthchecks
Without curl or wget in the image, healthchecks need one of two approaches: use a small binary you write into the app itself, or rely entirely on Kubernetes probes. The first approach:
HEALTHCHECK --interval=30s --timeout=3s \
CMD ["/healthcheck"]
In Kubernetes it’s much simpler — an httpGet probe runs entirely outside the container, requiring no binary inside. For a deeper look at how Docker healthchecks and restart policies interact in non-Kubernetes environments, that’s worth a separate read:
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
When You SHOULD and SHOULD NOT Use Distroless
Use it when: your app is stable, running long-term in production, your team is comfortable with multi-stage builds, and you’re using Kubernetes (healthchecks become much easier).
Think carefully when: your app needs to run shell scripts in the entrypoint, it calls external system commands (subprocess, exec.Command), or your team is still in active development and needs to debug frequently.
For cases where a minimal shell is needed, alpine remains a well-balanced choice — ~5MB, has /bin/sh, and far fewer CVEs than Ubuntu.
Distroless isn’t a silver bullet. But for stateless microservices written in Go, Java, or pure Python, it’s the simplest way to improve your security posture without adding operational overhead.

