← Back to DevBytes

Docker Distroless Images: Production Guide

What Are Docker Distroless Images?

Distroless images are minimal container images that contain only your application and its runtime dependencies. They strip away everything you don't need: no package managers, no shells, no command-line utilities, no standard Unix tools — not even a bash or sh binary. The term "distroless" literally means "without a distribution" — there is no operating system distribution layer like Debian, Alpine, or Ubuntu bundled inside the image.

Google originally pioneered the concept with their distroless GitHub repository, which provides pre-built base images for various language runtimes. These images are constructed from a minimal Debian or Alpine foundation, then stripped down to the absolute bare minimum required to run applications written in a specific language. For example, a Java distroless image contains only the JVM and core libraries — nothing more.

Think of a standard Docker image as a fully furnished apartment with a kitchen, utensils, cleaning supplies, and a toolbox. A distroless image is more like a single-purpose workspace that contains exactly what you need for one task and absolutely nothing else.

Key Characteristics

Available Distroless Base Images

Google maintains a set of production-ready distroless images on gcr.io. Here are the most commonly used ones:

# Static (for Go, Rust, C++ binaries that don't need libc)
gcr.io/distroless/static

# Base with minimal glibc, libssl, and ca-certificates
gcr.io/distroless/base

# Language-specific images
gcr.io/distroless/java-debian10
gcr.io/distroless/nodejs-debian10
gcr.io/distroless/python3-debian10

# With debug tag (includes busybox shell for debugging)
gcr.io/distroless/static:debug
gcr.io/distroless/base:debug

The :debug variants include a busybox shell, which is useful during development and troubleshooting. However, for production deployments, you should always use the non-debug versions to maintain the security benefits.

Why Distroless Images Matter

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Security: Drastically Reduced Attack Surface

Every binary, library, and utility inside a container image represents a potential attack vector. Standard container images ship with hundreds of binaries — curl, wget, bash, python, perl, and dozens of system utilities. Each of these carries its own set of CVEs (Common Vulnerabilities and Exposures). When a vulnerability scanner like Trivy, Clair, or Grype scans your image, every single binary contributes to the vulnerability count.

Distroless images eliminate this noise. Without a shell, an attacker who compromises your application cannot easily spawn a reverse shell, download malicious payloads, or pivot through the container using built-in tools. This follows the principle of least privilege applied at the image layer — your container gets only what it strictly needs to function.

Reduced Image Size and Faster Deployments

Smaller images mean faster pull times, reduced bandwidth costs, and less disk consumption across your container registry, CI/CD pipeline, and Kubernetes nodes. In a large-scale deployment where hundreds of pods may be scheduled per minute, the difference between pulling a 2 MB image and a 150 MB image compounds dramatically.

# Example size comparison
REPOSITORY                         SIZE
ubuntu:22.04                       77.8 MB
debian:bullseye-slim               80.5 MB
alpine:3.19                        7.05 MB
gcr.io/distroless/static           2.05 MB
gcr.io/distroless/base             2.40 MB

For Go applications compiled as static binaries, you can achieve final image sizes under 10 MB — application code included. This dramatically speeds up rolling updates, rollbacks, and node scaling operations.

Compliance and Audit Readiness

Many compliance frameworks (PCI DSS, SOC 2, HIPAA) require organizations to minimize their software footprint and justify every installed package. Distroless images simplify compliance audits because there is virtually nothing to justify — the image contains only your application code and the language runtime. Vulnerability scanners produce near-zero findings for the base image layer, letting you focus on your application dependencies exclusively.

Forced Immutability and Ephemeral Patterns

Without a shell, operators and developers cannot "just hop in and fix something" inside a running container. This enforces the container-as-immutable-deployment paradigm. If something goes wrong, you debug externally using logs, metrics, and traces — then fix the issue in your build pipeline and redeploy. This discipline leads to more robust, reproducible production systems.

How to Use Distroless Images

Step 1: Understand Your Application's Runtime Requirements

Before adopting distroless, determine exactly what your application needs at runtime. Static binaries (Go, Rust with musl) can use gcr.io/distroless/static. Applications requiring dynamic linking against glibc need gcr.io/distroless/base. Java applications need gcr.io/distroless/java-debian10, which bundles the JVM.

If your application shells out to external commands (e.g., using os/exec in Go or subprocess in Python), those commands must be present in the final image or the calls will fail. Audit your code for any assumptions about shell availability.

Step 2: Use Multi-Stage Builds

Multi-stage builds are the essential companion to distroless images. You compile or package your application in a full-featured build stage (with compilers, package managers, and all tooling), then copy only the final artifact into the minimal distroless runtime image.

Here is a complete example for a Go application:

# Build stage: uses full Golang image with compiler toolchain
FROM golang:1.22-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .

# Build a static binary with no libc dependency
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server .

# Runtime stage: distroless static image
FROM gcr.io/distroless/static

# Copy the compiled binary from the builder stage
COPY --from=builder /app/server /server

# Copy any additional static assets if needed
COPY --from=builder /app/config /config

EXPOSE 8080

# Distroless images use ENTRYPOINT, not CMD, for the main process
ENTRYPOINT ["/server"]

This produces a final image containing only the Go binary and the tiny distroless static base. Total image size is typically under 10 MB.

Step 3: Working with Node.js Applications

Node.js applications require the Node runtime, which the distroless Node.js image provides. Here is a complete multi-stage Dockerfile for a Node.js app:

# Build stage: install dependencies and compile if needed
FROM node:20-alpine AS builder

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production

COPY . .

# Runtime stage: distroless Node.js image
FROM gcr.io/distroless/nodejs20-debian12

WORKDIR /app

# Copy node_modules and application code
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app /app

EXPOSE 3000

ENTRYPOINT ["node", "index.js"]

The distroless Node.js image includes the Node binary and essential system libraries. It does not include npm, so all dependency installation must happen in the build stage.

Step 4: Python Applications

Python distroless images ship with the Python interpreter and core standard library modules. You must vendor all pip dependencies during the build stage and copy them over. Here is a complete example:

# Build stage: install Python dependencies
FROM python:3.12-alpine AS builder

WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt -t /deps

COPY . .

# Runtime stage: distroless Python image
FROM gcr.io/distroless/python3-debian12

WORKDIR /app

# Copy installed dependencies to the distroless image's site-packages path
COPY --from=builder /deps /usr/lib/python3.12/site-packages
COPY --from=builder /app /app

EXPOSE 8000

ENTRYPOINT ["python", "main.py"]

Note that we install dependencies to a specific directory (/deps) and copy them to the exact path where Python looks for packages inside the distroless image. The target path varies by Python version and distroless image variant, so verify it beforehand.

Step 5: Java Applications with JLink

For Java applications, you can further minimize the image by using jlink to create a custom JRE containing only the modules your application actually uses:

# Build stage: compile and create minimal JRE
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /app
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN ./mvnw dependency:resolve
COPY src ./src
RUN ./mvnw package -DskipTests

# Use jlink to create a minimal runtime
RUN jlink --add-modules java.base,java.logging,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument,jdk.unsupported \
    --strip-debug --no-man-pages --no-header-files --compress=2 \
    --output /custom-jre

# Runtime stage
FROM gcr.io/distroless/java-debian12

COPY --from=builder /app/target/*.jar /app/app.jar
COPY --from=builder /custom-jre /custom-jre

ENV JAVA_HOME=/custom-jre
ENV PATH="/custom-jre/bin:${PATH}"

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Step 6: Debugging Distroless Containers

When things go wrong and you need shell access for debugging, use the :debug variants temporarily. These images include a busybox shell at /busybox/sh. You can launch a debug container alongside your running pod in Kubernetes:

# Launch a debug sidecar in the same pod (ephemeral container)
kubectl debug -it my-pod --image=gcr.io/distroless/static:debug --target=my-container

For Docker, you can override the entrypoint to get a shell:

# Only works with :debug images
docker run -it --entrypoint /busybox/sh gcr.io/distroless/static:debug

Remember: never deploy :debug images to production. They reintroduce the attack surface you worked to eliminate.

Best Practices for Distroless Images in Production

1. Always Use Multi-Stage Builds

Never attempt to install packages or run setup scripts directly in a distroless image — you cannot. The build stage is where all preparation happens. Keep the build stage as close to the application source as possible, and treat the distroless runtime stage as a pure copy operation.

# Pattern: build → extract → copy
FROM  AS builder
# ... all compilation, dependency installation, asset generation ...

FROM gcr.io/distroless/
COPY --from=builder /path/to/artifacts /destination
ENTRYPOINT ["/binary"]

2. Prefer Static Binaries When Possible

If your language supports compiling to a statically linked binary (Go, Rust, C with musl), use gcr.io/distroless/static. This image is the absolute smallest and contains zero libraries — just the filesystem layout, a /tmp directory, and certificate authorities. It's the gold standard for minimalism.

# Go static compilation flags
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
RUN go build -a -installsuffix cgo -ldflags="-w -s" -o app .

3. Handle Signals Correctly

Distroless images lack an init system like tini or dumb-init. Your application process runs as PID 1 and must handle SIGTERM and SIGINT properly for graceful shutdown. In Kubernetes, when a pod is terminated, it receives SIGTERM followed by SIGKILL after a grace period. Ensure your application catches OS signals and shuts down cleanly.

For applications that spawn child processes or don't handle signals well, include a minimal init system by copying tini into the distroless image:

# Build stage: download tini
FROM alpine:latest AS tini-builder
RUN apk add --no-cache tini

# Runtime: copy tini into distroless
FROM gcr.io/distroless/static
COPY --from=tini-builder /sbin/tini /tini
COPY --from=builder /app/server /server
ENTRYPOINT ["/tini", "--", "/server"]

4. Manage CA Certificates

The gcr.io/distroless/static image includes CA certificates at /etc/ssl/certs/ca-certificates.crt, which enables TLS connections. The base and language-specific images also include them. If your application makes outbound HTTPS requests, verify that the image variant you choose includes the CA bundle. If you need custom or corporate CA certificates, copy them during the build stage:

COPY custom-ca.crt /etc/ssl/certs/
RUN ln -s /etc/ssl/certs/custom-ca.crt /etc/ssl/certs/$(openssl x509 -hash -noout -in /etc/ssl/certs/custom-ca.crt).0

5. Use Non-Root Users

Distroless images typically run as a non-root user (often nonroot with UID 65532). Verify the user in the base image and ensure your application does not require privileged operations. If your application needs to bind to a low port (below 1024), use Kubernetes' net.ipv4.ip_unprivileged_port_start sysctl or a service mesh to handle port remapping rather than running as root.

# Verify the user configured in the distroless image
docker run --rm gcr.io/distroless/static id
# Output: uid=65532(nonroot) gid=65532(nonroot)

6. Keep Debug Images for Development Only

Establish a convention in your CI/CD pipeline that production builds use non-debug distroless images, while development/staging deployments may use :debug variants. Tag your images accordingly:

# Production Dockerfile
FROM gcr.io/distroless/static
# ...

# Debug Dockerfile (for dev/staging)
FROM gcr.io/distroless/static:debug
# ...

Or use a build argument to switch:

ARG DEBUG=false
FROM gcr.io/distroless/static${DEBUG:+:debug}
# ...

7. Scan and Verify Your Images

Even though distroless images minimize the attack surface, you should still scan them alongside your application layer. Use tools like Trivy, Grype, or Docker Scout in your CI pipeline:

# Scan with Trivy
trivy image my-app:latest

# Scan and fail on critical vulnerabilities
trivy image --severity CRITICAL --exit-code 1 my-app:latest

Distroless images typically produce dramatically cleaner scan results compared to traditional base images, but your application dependencies still need attention.

8. Handle Logging and Observability

Without a shell, you cannot tail -f log files inside the container. Your application must write logs to stdout/stderr, which Docker and Kubernetes capture natively. Configure your logging library to use console output rather than file-based logging. For metrics and debugging, integrate with an APM (Application Performance Monitoring) solution or expose a health-check HTTP endpoint that can be probed externally.

9. Plan for Troubleshooting Without a Shell

Adopt debugging practices that work without shell access. Rely on:

10. Build Your Own Distroless Images When Needed

Google's distroless images cover common runtimes, but you may need a custom minimal image for niche languages or specific library requirements. You can construct your own distroless-style image using a multi-stage approach and a minimal base like scratch or alpine:

# Custom distroless-like image for a Rust application
FROM rust:1.78-alpine AS builder
WORKDIR /app
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl

# Create a minimal runtime from scratch
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]

Using scratch (the empty Docker image) gives you the ultimate minimal image, but you must manually include CA certificates and any required shared libraries. For more complex needs, start from gcr.io/distroless/base and add only what's strictly necessary.

Complete Production-Ready Example

Here is a fully production-ready Dockerfile that incorporates many of the best practices discussed above. This example builds a Go API server with proper signal handling, non-root execution, and a multi-stage distroless deployment:

# Stage 1: Build the Go binary
FROM golang:1.22-alpine AS builder

RUN apk add --no-cache ca-certificates git

WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# Build static binary with stripped symbols
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -a -installsuffix cgo \
    -ldflags="-w -s -extldflags '-static'" \
    -o /app/server .

# Stage 2: Prepare certificates and any static assets
FROM alpine:3.19 AS assets

RUN apk add --no-cache ca-certificates

# Copy certificates to a clean location
RUN cp -r /etc/ssl/certs /certs

# Stage 3: Minimal distroless runtime
FROM gcr.io/distroless/static

# Copy CA certificates for TLS
COPY --from=assets /certs /etc/ssl/certs

# Copy the compiled binary
COPY --from=builder /app/server /server

# Copy static configuration
COPY --from=builder /src/config.yaml /config.yaml

EXPOSE 8080

# Run as non-root user (already default in distroless/static)
USER 65532

ENTRYPOINT ["/server"]

This Dockerfile produces a final image of approximately 8–10 MB, contains zero unnecessary binaries, has no shell, and runs as a non-root user. The build is reproducible and fully self-contained within the Dockerfile.

Conclusion

Distroless images represent a significant evolution in container security and operational efficiency. By stripping away everything except the bare minimum runtime, you dramatically reduce your attack surface, shrink image sizes, and enforce immutability in your deployment pipeline. The initial learning curve — adapting to life without a shell, restructuring Dockerfiles for multi-stage builds, and handling signals correctly — pays dividends in production reliability and security posture.

The key takeaways are straightforward: always use multi-stage builds, prefer static binaries when possible, never deploy :debug images to production, and invest in observable, shell-less debugging practices like structured logging and health endpoints. Start with Google's pre-built distroless base images for your language runtime, then progressively tighten your images as you understand your application's exact runtime requirements. The journey toward truly minimal containers is one of the highest-leverage investments a development team can make in securing their production infrastructure.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles