What are distroless, Alpine and Chainguard base images?

Alpine is a general-purpose Linux distribution built on musl libc and BusyBox. Its container image ships a shell (/bin/sh from BusyBox) and the apk package manager, so you can install packages at build time or at runtime. That makes Alpine small but not minimal in the hardening sense: anything an attacker can reach in the container, including a shell and a package manager, is available.

Google's distroless images take the opposite approach. They are assembled from Debian packages but contain only a language runtime (or nothing, in the static variant) plus its shared-library dependencies, CA certificates and timezone data. Standard tags have no shell and no package manager. Separate :debug tags add a BusyBox shell, and :nonroot tags change the default user.

Chainguard images are built from Wolfi, a glibc-based package ecosystem designed specifically for containers. Runtime variants follow the distroless model: no shell, no apk. -dev variants add both, so they can serve as build stages or troubleshooting images.

Property Alpine Distroless Chainguard
libc musl glibc (none in static) glibc
Shell BusyBox sh None (:debug adds BusyBox) None (-dev adds one)
Package manager apk None None (-dev has apk)
Default user root root (:nonroot available) Non-root in most images
Debug variant Not needed :debug -dev
Package source Alpine repositories Debian Wolfi
CVE triage Alpine maintainers Debian security, inherited Chainguard, via Wolfi

The rows that should drive your choice are libc compatibility, whether a shell exists at runtime, and who patches CVEs. Image size matters less than any of them.

Why does glibc vs musl matter more than image size?

Alpine's real difference from the other two images is its C library. Alpine ships musl; distroless and Chainguard images are glibc-based, so everything below applies only to Alpine.

musl behaves differently from glibc in places your code rarely checks:

  • DNS resolution. musl sends queries to all configured nameservers in parallel and takes the first answer. Its handling of search domains and ndots also differs from glibc's. Historically it did not retry over TCP when a response was truncated. In Kubernetes, with long search lists and large DNS responses, this can show up as intermittent lookup failures that never reproduce on a developer laptop.
  • Locales. musl has minimal locale support. Collation and some locale-dependent formatting do not behave as they do under glibc, which can change sort order or string comparisons.
  • Threading. musl's default thread stack size is much smaller than glibc's. Code with deep recursion can overflow it and crash, even though the same code runs fine on glibc.
  • Memory allocation. musl's allocator is designed for simplicity and hardening, not throughput. Allocation-heavy, multithreaded workloads can run measurably slower on it.

Native code makes this worse. Many Python packages publish only manylinux (glibc) wheels. On Alpine, pip falls back to compiling from source, which adds a toolchain to your build stage and lengthens builds. Node native addons and vendor-supplied binaries often target glibc too. They either fail to load or depend on compatibility shims.

Avoid Alpine when you run native extensions, glibc-only binaries, DNS-heavy services in Kubernetes, or latency-sensitive multithreaded code. Saving a few megabytes does not justify a production incident that takes days to trace back to libc.

How do you read CVE scanning results for minimal images?

A scanner does not inspect binaries for flaws. It reads the image's package database (dpkg status entries on Debian-based images, the apk installed database on Alpine and Wolfi-based images), extracts each package name and version, and matches them against advisory feeds. Those feeds are usually the distro's own security tracker, supplemented by general vulnerability databases. The count you see reflects that metadata and those feeds. It is not an audit of the code.

That is why a low number can mean three different things:

  • Fewer packages. Less software installed gives the scanner less to match against.
  • Faster patching. The vendor ships fixed versions quickly, so known issues stop matching sooner.
  • Triage. The distro's tracker has marked an issue as not affecting its build, or as too minor to fix, and the scanner suppresses it.

Distroless images are assembled from Debian packages, so they inherit Debian's triage and release cadence. A CVE that Debian has not yet fixed stays flagged in a distroless image, even though the image is otherwise very small.

Treat a zero with suspicion until you have confirmed two things: the scanner understands the image's package format, and it consumes the matching advisory feed. If a scanner cannot find a package database, it may report nothing at all. For the findings that remain, check whether the vulnerable code is actually reachable in your workload. A flaw in a library your process never loads carries different risk from one in your request path.

Application dependencies often contribute more findings than the base image, and changing the base does nothing about them. A clean base scan can create false confidence about the rest of the image.

How does the multi-stage build pattern make minimal runtimes work?

The multi-stage build is what makes these images practical. You compile and resolve dependencies in a full image that has a shell, compilers and a package manager, then copy only the finished artifact into the minimal runtime:

Build stage (toolchain, package manager, source) → artifact (binary, or app plus installed dependencies) → runtime stage (artifact and runtime only)

For Go, build with CGO_ENABLED=0 to get a static binary, then copy it onto a scratch or distroless static base. For Python or Node, install dependencies in Chainguard's -dev variant, which includes a shell and package tooling. Then copy the application and its installed packages into the matching non-dev runtime image. Both stages must use the same language version and libc, or native extensions will fail to load.

Some runtime assets are easy to miss because their absence only shows up in production:

  • CA certificates. Without them, outbound TLS calls fail.
  • Timezone data. Without it, time zone lookups fail. In Go you can avoid this by importing time/tzdata.
  • Locale files. Without them, the app falls back to default formatting.

Scratch provides none of these files. Distroless and Chainguard bases include certificates, but you should confirm what else your application needs.

Without a shell, entrypoint scripts and CMD curl ... || exit 1 health checks stop working. Use exec-form commands. Move startup logic into the application, and use a health subcommand or an orchestrator HTTP probe for health checks.

The tradeoff is that work moves into the build pipeline. Dockerfiles get longer and builds run more often. Engineers also lose the habit of fixing things inside a running container, which the next section addresses.

# ---- Build stage: full toolchain, shell, package manager ----
FROM golang:1.22 AS build
WORKDIR /src

# Resolve dependencies first so this layer is cached across source changes
COPY go.mod go.sum ./
RUN go mod download

COPY . .
# CGO_ENABLED=0 produces a static binary with no libc dependency.
# -tags timetzdata embeds the zoneinfo database, equivalent to
# importing _ "time/tzdata", so time zone lookups work without system files.
RUN CGO_ENABLED=0 go build -trimpath -tags timetzdata \
    -ldflags="-s -w" -o /out/app ./cmd/app

# ---- Runtime stage: artifact plus minimal OS files only ----
# distroless/static ships CA certificates and a nonroot user, but no shell
FROM gcr.io/distroless/static-debian12:nonroot

# On a scratch base you would copy the CA bundle yourself instead:
#   FROM scratch
#   COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

COPY --from=build /out/app /app

# Exec form only. There is no /bin/sh, so 'CMD curl ... || exit 1' cannot run.
# The binary implements its own 'healthcheck' subcommand.
# Kubernetes ignores HEALTHCHECK; use an HTTP probe there instead.
HEALTHCHECK --interval=30s --timeout=3s CMD ["/app", "healthcheck"]

# Startup logic that used to live in an entrypoint script belongs in the app
ENTRYPOINT ["/app"]
CMD ["serve"]

A multi-stage Dockerfile that compiles a static Go binary with embedded timezone data and runs it on a shell-less distroless base, using exec-form ENTRYPOINT and HEALTHCHECK commands.

How do you debug a container that has no shell?

Losing that habit is the point: when the image has no shell, debugging moves to tools built for it.

Distroless debug tags. Distroless publishes :debug variants of its images that add a BusyBox shell. Swapping the tag locally lets you inspect file layout, environment variables and permissions against the same base as production.

Chainguard -dev variants. Chainguard's -dev images add a shell and the apk package manager. They fit local development and staging, where you may need to install a diagnostic tool temporarily. Treat them as a separate artifact from the image you ship.

Kubernetes ephemeral containers. For a running pod, kubectl debug attaches a temporary container without rebuilding or restarting anything:

kubectl debug -it pod/api-7c9f --image=busybox --target=api

The --target flag joins the target container's process namespace. You can see its processes and, permissions allowing, reach its filesystem through /proc/<pid>/root. The production image stays unchanged, and the debug container goes away with the pod.

Needing to debug is not a reason to ship a shell to production. These options cover the cases without adding attack surface to the runtime image.

Write the runbook before the incident

Ad hoc debugging fails in a predictable way. Someone tries kubectl exec, finds no shell, and then spends incident time working out which debug image to use, which RBAC permissions allow ephemeral containers, and whether the cluster permits them at all. A short runbook settles those questions in advance. It should name approved debug images, list the required permissions, and give the exact commands. That turns a stall in the middle of an incident into a step you can copy and paste, and it keeps everyone from creating their own workarounds.

#!/usr/bin/env bash
# Debug runbook: no-shell containers. Copy-paste steps, fill in the variables.
set -euo pipefail

NS="payments"           # namespace of the affected workload
POD="api-7c9f"           # pod name from: kubectl get pods -n "$NS"
TARGET="api"             # container name inside the pod
DEBUG_IMAGE="busybox:1.36"  # approved debug image; keep this list in the runbook

# Step 1: confirm you may create ephemeral containers before you need to.
# kubectl debug patches the pods/ephemeralcontainers subresource.
kubectl auth can-i patch pods --subresource=ephemeralcontainers -n "$NS"

# Step 2: attach a temporary container that shares the target's process namespace.
# The production image is not rebuilt and the pod is not restarted.
kubectl debug -it "pod/$POD" -n "$NS" --image="$DEBUG_IMAGE" --target="$TARGET"

# Step 3 (inside the debug shell): find the app's PID and inspect its filesystem.
#   ps                       # locate the target process
#   ls /proc/<pid>/root/     # app filesystem, if permissions allow
#   cat /proc/<pid>/environ | tr '\0' '\n'

# Step 4: record what you ran. Ephemeral containers stay in the pod spec
# until the pod is deleted.
kubectl get pod "$POD" -n "$NS" -o jsonpath='{.spec.ephemeralContainers[*].name}'

# Local reproduction only: never ship these tags.
# Distroless debug tag (BusyBox shell):
#   docker run --rm -it --entrypoint=sh gcr.io/distroless/base-debian12:debug
# Chainguard -dev variant (shell + apk):
#   docker run --rm -it --entrypoint=sh cgr.dev/chainguard/python:latest-dev

A runbook script that checks RBAC for ephemeral containers, attaches a pinned debug image to a running container with kubectl debug --target, and keeps distroless :debug and Chainguard -dev images limited to local use.

Hardening beyond the base: non-root, read-only filesystems and capabilities

A minimal package list only reduces what an attacker finds after getting in. The runtime settings decide what that foothold can do. The three bases ship with different defaults here.

  • Distroless publishes nonroot tag variants that set the image user to an unprivileged UID. The standard tags still run as root, so the tag you pick matters.
  • Chainguard images generally default to a non-root user. Check the image config before you rely on that, because some variants, such as development images that include a shell and package manager, may differ.
  • Alpine runs as root unless your Dockerfile adds a user and switches to it with USER.

Don't depend on any of these defaults alone. Set the controls in the Kubernetes securityContext, where the cluster enforces them no matter which image someone pushes:

  • runAsNonRoot: true makes the kubelet refuse to start a container that would run as UID 0. The kubelet can only verify a numeric UID. If the image declares a username, also set runAsUser to a numeric value.
  • readOnlyRootFilesystem: true stops an attacker from dropping binaries or changing config inside the container. Mount an emptyDir at the specific paths that need writes, such as /tmp or a cache directory, instead of relaxing the whole filesystem.
  • allowPrivilegeEscalation: false sets the kernel's no_new_privs flag, so setuid binaries and file capabilities can't grant more privilege than the process started with.
  • Dropping ALL capabilities removes the kernel privileges that a container runtime grants by default. Add individual capabilities back only when a test fails without them. For example, listening on a port above 1024 avoids needing NET_BIND_SERVICE at all.
apiVersion: v1
kind: Pod
metadata:
  name: api-server
spec:
  securityContext:
    # Kubelet refuses to start any container that would run as UID 0.
    runAsNonRoot: true
    # Numeric UID so the kubelet can verify it, even if the image sets a username.
    runAsUser: 65532
    runAsGroup: 65532
    fsGroup: 65532
  containers:
    - name: api
      image: registry.example.com/team/api:1.4.2
      ports:
        - containerPort: 8080   # above 1024, so NET_BIND_SERVICE is not needed
      securityContext:
        allowPrivilegeEscalation: false   # sets no_new_privs; setuid binaries can't elevate
        readOnlyRootFilesystem: true      # no writes to the image filesystem
        capabilities:
          drop: ["ALL"]                   # remove the runtime's default capability set
      volumeMounts:
        # Writable scratch space only where the app actually needs it.
        - name: tmp
          mountPath: /tmp
        - name: cache
          mountPath: /var/cache/api
  volumes:
    - name: tmp
      emptyDir: {}
    - name: cache
      emptyDir:
        sizeLimit: 128Mi

A Pod spec that enforces a non-root numeric UID, a read-only root filesystem, no privilege escalation and no capabilities, and uses emptyDir volumes for the few paths that need writes, regardless of which base image the container uses.

How should you pin, rebuild and verify base images?

A minimal base only stays low-CVE if you keep pulling in the publisher's fixes. An image built six months ago from a clean base will accumulate findings as new CVEs are disclosed against the packages it already contains. Regular rebuilds and redeploys matter more than which base you started with.

Pin by digest, update by pull request

A floating tag like :latest, or even a version tag, can point to different content tomorrow. That gives you neither reproducibility nor control: two builds of the same commit can differ, and you cannot tell when a change arrived. Pin the base by digest instead, in the form FROM <registry>/<image>@sha256:<digest>. Then use an automated dependency-update bot to open a pull request whenever the upstream digest changes. Each bump becomes a reviewable, revertible commit that runs through CI like any other change.

This has costs. Frequent bumps increase CI load and cause deployment churn across many services. A new base can also change behavior silently, for example through a library upgrade or a changed default. Without automated tests that exercise the real binary in the real image, auto-merging these PRs is a gamble.

Verify what you pull

Where the publisher provides them, check signatures, provenance attestations and SBOMs before building. In practice this is a pipeline step that runs a Sigstore-compatible verifier against the pinned digest and an expected signer identity, and fails the build on a mismatch. Record the base digest for every release, for example as an image label or in release metadata. When a CVE lands, incident response can then answer directly whether an affected base ever shipped.

#!/usr/bin/env bash
# CI step: refuse unpinned bases, verify the base signature, record the digest.
# The Dockerfile's first line is pinned by digest and bumped by an update-bot PR:
#   FROM registry.example.com/base/static@sha256:<64-hex-digest>
set -euo pipefail

# Read the base reference straight from the Dockerfile so there is one source of truth.
BASE_REF=$(awk '/^FROM /{print $2; exit}' Dockerfile)

# Fail fast if someone replaced the digest with a floating tag.
case "$BASE_REF" in
  *@sha256:*) ;;
  *) echo "base image is not pinned by digest: $BASE_REF" >&2; exit 1 ;;
esac

# Verify the publisher's keyless signature for this exact digest.
# Identity and issuer are what you expect the publisher's build system to sign as;
# a mismatch makes cosign exit non-zero and stops the build.
cosign verify \
  --certificate-identity="" \
  --certificate-oidc-issuer="" \
  "$BASE_REF" > /dev/null

# Build, stamping the base name and digest into the image using the standard
# OCI annotation keys so incident response can query which base a release shipped.
docker build \
  --label "org.opencontainers.image.base.name=${BASE_REF%@*}" \
  --label "org.opencontainers.image.base.digest=${BASE_REF#*@}" \
  -t "registry.example.com/myteam/api:${GIT_COMMIT:-dev}" \
  .

# Also keep the base digest in release metadata, outside the image itself.
echo "${GIT_COMMIT:-dev} ${BASE_REF}" >> release-bases.txt

A CI script that rejects floating base tags, checks the pinned base digest's signature with cosign against an expected signer identity, and records that digest as an image label and in release metadata.

Which base image should you choose for your workload?

Start from the workload's runtime requirements, then weigh support and supply-chain needs.

Workload Good fit Why
Static Go (or other fully static) binary Distroless static or a scratch-style base Nothing to link against, so the runtime needs only CA certificates, timezone data and a non-root user
JVM, Python or Node with glibc native dependencies Distroless or Chainguard language images glibc compatibility without a shell or package manager in the final image
Needs runtime package installs, or a team migrating that wants a familiar shell Alpine apk and a shell are available, but you accept musl and a larger attack surface
Requires vendor SLAs, version-pinned images or FIPS-oriented builds Chainguard (evaluate commercial terms directly) These are paid offerings, so confirm scope and pricing with the vendor

Each option has trade-offs. Chainguard makes you depend on a single vendor for rebuild cadence and image availability. Its free tier has historically offered fewer tags and versions than its paid tiers, and those terms can change, so check what you can pull today before you commit. Distroless offers a narrower set of runtimes and versions, so an older interpreter or an uncommon language may not have an image.

Standardize on one or two base families. Each additional family brings its own scanner behaviour, package naming, debugging runbook and set of things engineers must remember during an incident. Consolidating is where most of the engineering time savings come from, more than any difference between the bases themselves.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to distroless vs alpine, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.