Skip to content
Stribog

Supply Chain

All writing

Distroless Without a Vendor: Building Your Own Base Images

Distroless base images you build yourself: apko and melange, reproducible builds, signed provenance, and the rebuild cadence that decides build versus buy.

Stribog13 min read

Every container inherits the trust posture of a base image somebody maintains — usually somebody external, unpaid, and in no architecture document. During 2025 that arrangement became commercial, and teams who had never budgeted for base images acquired a procurement decision. This article starts one layer below our guide to signing and attesting the artefacts you already have: not the built image, but what is inside it.

What Distroless Actually Means, and What It Does Not

The definition is narrower than the marketing. The distroless project states that its images "contain only your application and its runtime dependencies" and "do not contain package managers, shells or any other programs you would expect to find in a standard Linux distribution." That describes contents, not vulnerabilities: fewer packages means fewer that can be vulnerable, not none. A "CVE-free" claim describes a catalogue, not the technique. Four consequences follow:

  • ENTRYPOINT must be vector form. With no shell present, the README says, it must be given in vector form to avoid the runtime prefixing one. Shell form fails at start, not at build.
  • Debugging needs another image. The bases lack shell access, so a parallel :debug set per language provides a busybox shell.
  • The non-root identity is numeric. The project's build variables define NONROOT = 65532, alongside NOBODY = 65534 and ROOT = 0.
  • The base moves under you. Images are based on Debian 13 (trixie); an unsuffixed reference selects -debian13 today but, the README warns, will change to a newer Debian.

Size is the number most often mangled. The README puts its smallest image, static-debian13, at around 2 MiB against alpine at roughly 5 MiB and debian at 124 MiB: figures comparable only to each other, since the README never states its basis. Docker Hub separately reports linux/amd64 debian:stable-slim as 29,780,760 bytes on a different basis; never build a ratio across the two.

2025–2026: Hardened Base Images Became a Product Category

In an announcement issue opened 2025-07-16, Bitnami said that as of 28 August 2025 all existing container images, including older and versioned tags, would move from docker.io/bitnami to a docker.io/bitnamilegacy repository receiving no further updates or support, for temporary migration only. What stayed free was a focused set of more hardened images, intended for development, on the latest tag alone. Production-ready containers and Helm charts moved to the paid Bitnami Secure Images offering.

Bitnami scheduled brownouts ahead of the deletion, each making a set of 10 images temporarily unavailable for 24 hours, published as three windows: 28–29 August 2025, 2–3 September 2025, and 17–19 September 2025. Separately, after evaluating impact and community feedback, it postponed deletion of the public catalogue to 29 September 2025. Vendor dates move in both directions.

Now read the registry rather than the reporting. On 2026-08-14 docker.io/bitnami has not been deleted: Docker Hub lists the organisation active under the display name Bitnami Secure Images, and bitnami/nginx:latest was pushed that day. bitnami/openldap shows the other half — last updated 2025-08-29, its Hub page stating the image is no longer free through Docker Hub, offered instead through a commercial subscription.

Chainguard draws the line elsewhere. Its container overview documentation, footer "Last updated: 2025-07-23", states Free images are limited to the latest build of a given image, tagged latest and latest-dev, while Production containers carry patch SLAs, FIPS readiness and unique time-stamped tags at specific major and minor versions. The same page names two pricing options: Per-Image and Catalog.

"Hardened base images went paid" is a tidy summary, and false against the rest of the category. Docker publishes Docker Hardened Images across three subscriptions, Community, Select and Enterprise, with FIPS/STIG variants and SLA-backed critical-CVE patching excluded from Community, and states DHI's core features are free under Apache 2.0. Minimus offers a Community plan at $0 beside a custom-priced Enterprise plan, private builds and SLA-backed support being the upgrade. Four vendors, four boundaries, all read on 2026-08-14: the question is which one you depend on, and what happens when it moves.

Three Paths to a Base Image You Built Yourself

apko and melange — declarative images, packages you can rebuild

apko builds an OCI image straight from a YAML declaration: keyring, repositories, package list. No Dockerfile and no build container, so far fewer ways to acquire undeclared contents. Its package source is Wolfi, which its authors call an undistro: no kernel of its own, relying on the container runtime for one. Where a package does not exist, melange builds apk packages from declarative pipelines, framed by its authors as proving the provenance of every artifact in a software appliance; its gnu-hello example pins the source tarball by expected-sha256, which is what makes an input verifiable.

One warning: the published examples/wolfi-base.yaml sets cmd: /bin/sh -l, so it ships a shell and is not distroless. Write your own, noting that users, groups and run-as are children of accounts, with run-as matching a username or uid declared there.

yaml
# Build: apko build base.apko.yaml registry.internal/base/static:1 image.tar
contents:
  keyring:
    - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub
  repositories:
    - https://packages.wolfi.dev/os
  packages:
    - wolfi-baselayout
    - ca-certificates-bundle
    - tzdata
    - glibc

accounts:
  groups:
    - groupname: nonroot
      gid: 65532
  users:
    - username: nonroot
      uid: 65532
      gid: 65532
  # Must match a username or uid above; 65532 is distroless's own.
  run-as: 65532

environment:
  SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
  TZ: UTC

# No cmd or entrypoint: a base, not a runnable image.

archs:
  - x86_64
  - aarch64
base.apko.yaml — an organisation's own minimal base: no shell, no package manager in the output, a numeric non-root account, both architectures from one declaration.

A pinned Dockerfile against a dated package snapshot

Few teams can adopt a new build tool and a new package ecosystem at once. The Debian path gets most of the property with tools already in the pipeline: snapshot.debian.org is a wayback archive of Debian packages, addressable by date and version. One trap — an old snapshot's Release file has expired by the time you build against it, so apt rejects it unless you disable the validity check.

dockerfile
# syntax=docker/dockerfile:1
#
# rewrite-timestamp=true is an EXPORTER option, not a Dockerfile directive:
#   --output type=image,name=...,rewrite-timestamp=true,push=true
#
# Consumed by the frontend since BuildKit 0.11. 1785542400 = 2026-08-01T00:00:00Z.
ARG SOURCE_DATE_EPOCH=1785542400
ARG SNAPSHOT=20260801T000000Z

# linux/amd64 manifest digest for debian:stable-slim, read from Docker Hub
# on 2026-08-14. A tag is a name; only a digest is a pin.
FROM debian@sha256:0ef0f77425e6677ead26f893cb61707f7fc44467a480625d8974feb7ab2085fe AS rootfs
ARG SNAPSHOT
RUN set -eux; \
    rm -f /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; \
    echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/${SNAPSHOT}/ trixie main" \
      > /etc/apt/sources.list.d/snapshot.list; \
    apt-get -o Acquire::Check-Valid-Until=false update; \
    apt-get install -y --no-install-recommends ca-certificates tzdata; \
    mkdir -p /out/usr/lib/x86_64-linux-gnu /out/usr/lib64 /out/usr/share /out/etc/ssl/certs /out/tmp /out/home/nonroot; \
    ln -s usr/lib /out/lib; \
    ln -s usr/lib64 /out/lib64; \
    cp -a /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 \
          /usr/lib/x86_64-linux-gnu/libc.so.6 \
          /usr/lib/x86_64-linux-gnu/libm.so.6 \
          /usr/lib/x86_64-linux-gnu/libdl.so.2 \
          /usr/lib/x86_64-linux-gnu/libpthread.so.0 \
          /out/usr/lib/x86_64-linux-gnu/; \
    cp -a /usr/lib64/ld-linux-x86-64.so.2 /out/usr/lib64/; \
    cp -a /etc/ssl/certs/ca-certificates.crt /out/etc/ssl/certs/; \
    cp -a /usr/share/zoneinfo /out/usr/share/zoneinfo; \
    echo 'nonroot:x:65532:65532::/home/nonroot:/sbin/nologin' > /out/etc/passwd; \
    echo 'nonroot:x:65532:' > /out/etc/group; \
    chown -R 65532:65532 /out/home/nonroot; \
    chmod 1777 /out/tmp

FROM scratch
COPY --from=rootfs /out/ /
COPY --chown=65532:65532 ./dist/app /usr/local/bin/app
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt TZ=UTC
USER 65532:65532
# Vector form — no shell here to prefix it with.
ENTRYPOINT ["/usr/local/bin/app"]
Dockerfile — the Debian path: a minimal rootfs assembled from a dated snapshot, then the application on top. The copy list is amd64-specific by construction, because pinning by digest selects one architecture's manifest.

Two entries in that copy list decide whether the image runs at all. Under Debian's merged-/usr layout the loader is /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2, and /usr/lib64 only points at it; cp -a implies -d, so copying that path alone ships a link to nothing and the container fails as no such file or directory — an ENOENT naming your binary while meaning its loader. Certificates fail as quietly: /etc/ssl/certs is mostly links into /usr/share/ca-certificates, so ship the bundle and set SSL_CERT_FILE, not SSL_CERT_DIR.

Nix dockerTools — only if you already run Nix

A third path, worth it only if you already build with Nix. dockerTools names this section's failure mode outright: creation time defaults to 1970-01-01T00:00:01Z, and the manual cautions that setting created to "now" makes the image non-reproducible.

Reproducibility Is the Load-Bearing Property

A self-built base is worth the effort only if you can rebuild it and get the same thing. Otherwise it means a build you cannot reproduce, which is worse than a vendor image. apko's README claims the property outright ("Fully reproducible by default. Run apko twice and you will get exactly the same binary") and claims SBOM generation beside it. Verify both yourself.

The mechanism is a lockfile plus a hash of the archive both builds write — stricter than comparing image digests, and able to fail on packaging differences a digest would absorb. apko's --lockfile takes a .lock.json produced by apko lock and constrains package versions to the listed ones; its documented default is an empty string, meaning no constraints, so an unconfigured build is an unpinned build. Know what you lean on: lock is marked Hidden: true in the CLI source, pending feedback, and its sibling resolve carries a deprecation string. It works; it is not promised.

bash
#!/usr/bin/env bash
# Verify apko's "fully reproducible by default" claim on OUR packages,
# on OUR runners.
set -euo pipefail

CONFIG="${1:-base.apko.yaml}"
LOCKFILE="${CONFIG%.yaml}.lock.json"
TAG="registry.internal/base/static:repro-check"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

# Pin the build clock, or the builds differ for unrelated reasons.
export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-1785542400}"

# 'apko lock' is Hidden:true in the CLI source — usable, not promised.
apko lock "$CONFIG" --output "$LOCKFILE"

build_once() {
  local dir="$1"
  mkdir -p "$dir"
  apko build "$CONFIG" "$TAG" "$dir/image.tar" \
    --lockfile "$LOCKFILE" --sbom-path "$dir" >/dev/null
  sha256sum < "$dir/image.tar" | cut -d' ' -f1
}

a="$(build_once "$WORK/a")"
b="$(build_once "$WORK/b")"

if [ "$a" != "$b" ]; then
  echo "NOT REPRODUCIBLE: $a != $b" >&2
  echo "Diff the SBOMs first — a package version almost certainly moved:" >&2
  diff <(jq -S . "$WORK"/a/sbom-*.json) <(jq -S . "$WORK"/b/sbom-*.json) >&2 || true
  exit 1
fi

echo "reproducible: $a"
repro-gate.sh — two builds, one comparison. Runs on every change to the base declaration and fails the pipeline the day the artefact stops being deterministic.

That gate covers the apko path. The Dockerfile path has two knobs, and knowing only the first is the common failure: BuildKit has consumed SOURCE_DATE_EPOCH since 0.11, but that alone does not rewrite the timestamps of files inside the image — the exporter option rewrite-timestamp=true does. Set only the variable and every rebuild still yields a fresh digest.

The chain is only as strong as its weakest pin. Two of the three breaks close with configuration; the third is a property of admission control, and the honest response is to design around it rather than claim it away.

Provenance an Auditor Can Read

A reproducible build produces a claim; provenance makes it checkable by someone who was not there. docker buildx attaches provenance with --attest type=provenance, mode=min the default and mode=max the detailed variant; an SBOM is a separate flag, --attest type=sbom. For anything the build system does not emit, cosign attest --predicate <FILE> --type <TYPE> --key cosign.key <IMAGE> signs an arbitrary predicate.

The compliance reading is narrower than most assume. NIST SP 800-218 states practice PS.3.2 as: collect, safeguard, maintain and share provenance data for all components of each software release, for example in an SBOM. Its Example 4 requires updating that data whenever a component is updated. That is an obligation to keep provenance current, not a rebuild interval and not a cadence: those are engineering arguments, and the next section makes ours. For EU product obligations, see the Cyber Resilience Act and self-hosted pipelines.

Enforcing Base-Image Lineage in the Cluster — and Where You Cannot

Having built the base, you want the cluster to run nothing else, and admission control gets you most of the way. Ordinary policy-as-code territory, with one non-optional detail: a cluster-wide verifyImages rule without namespace exclusions blocks control-plane pods.

yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-own-base-images
spec:
  background: false
  rules:
    - name: images-from-our-registry
      match:
        any:
          - resources:
              kinds: [Pod]
      exclude:
        any:
          - resources:
              namespaces:
                - kube-system
                - kube-public
                - kube-node-lease
                - kyverno
                - platform-operators
      validate:
        failureAction: Enforce
        message: >-
          Images from registry.internal only. Enforces the NAME, not the
          ancestry: admission cannot see a Dockerfile FROM line.
        pattern:
          spec:
            =(initContainers):
              - image: "registry.internal/*"
            =(ephemeralContainers):
              - image: "registry.internal/*"
            containers:
              - image: "registry.internal/*"

    - name: verify-our-signature
      match:
        any:
          - resources:
              kinds: [Pod]
      exclude:
        any:
          - resources:
              namespaces:
                - kube-system
                - kube-public
                - kube-node-lease
                - kyverno
                - platform-operators
      verifyImages:
        - type: Cosign
          imageReferences:
            - "registry.internal/*"
          failureAction: Enforce
          # Admit by digest, never by tag: verify what runs.
          required: true
          mutateDigest: true
          verifyDigest: true
          attestors:
            - count: 1
              entries:
                - keys:
                    publicKeys: k8s://kyverno/base-image-cosign-pub
kyverno-own-base.yaml — one rule for where images come from, one for whether they are signed. Both exclude the namespaces whose pods must schedule for the cluster to survive the policy.

Now the limit. Kyverno documents a verifyImages rule's common configuration attributes as type, imageReferences, skipImageReferences, required, mutateDigest, verifyDigest, repository and imageRegistryCredentials; plus nested attestors, which check the attached signature, and attestations, signed metadata those attestors verify. Across that documented set, at admission time, nothing inspects a Dockerfile FROM lineage. "Only our base images" is enforced by naming and signature, never ancestry: an image built from something else, pushed to your registry and signed with your key, passes both rules above.

That is architectural, not a policy gap: lineage must be enforced where it is visible — in the build system and the registry that stores the result — by signing only an image the pipeline built from an approved base. Beneath admission, runAsNonRoot makes the kubelet validate at runtime that the image does not run as UID 0 and fail the container if it does, while runAsUser otherwise defaults to the image metadata's user — which is why the identity belongs in the base. The same reasoning governs the immutable host OS underneath.

The Cost Nobody Budgets: Rebuild Cadence

In our engagements, everything above is about a week of work for a competent platform engineer. Self-built bases do not fail on the first build; they fail on the two-hundredth. A base image is not a thing you make, it is a thing you keep making, and the schedule is the entire value of the exercise. An aged minimal image is not safer than a maintained large one — only smaller.

Compute your own number first. Multiply the images you maintain — every variant, because runtime times architecture times hardened-profile is how four becomes twenty — by the rebuilds each needs per year, by the cost of a rebuild that fails its way into production. In our engagements the count is what teams underestimate most: the platform's own tally is smaller than a grep for FROM across every repository.

  • Triage. New base, new package versions, new scan result, and somebody decides what blocks. trivy image --severity HIGH,CRITICAL with --exit-code is the mechanism; agreeing what that exit code means is the work.
  • Regression surface. A libc or certificate-bundle change reaches every dependent service at once: the strength of a shared base and its blast radius in one sentence.
  • Re-attestation and re-signing. Every rebuild is a new digest, SBOM, provenance statement and signature. Automated, that is free; manual, it is the step that silently stops happening.
  • The lockfile decision. Rebuilding without moving the lockfile reproduces the same vulnerabilities; moving it is the update. Automate the rebuild but not the bump and the pipeline goes green while nothing changed.

So write the cadence down as a commitment with an owner, and publish the age of the newest build of every base variant beside it. Nothing breaks when a base image goes unrebuilt — which is exactly why that number has to be visible.

The vendor column is not a single price and the build column is not a single build. Both sides are recurring costs, which is why comparing a one-off build effort against an annual subscription gets the answer wrong in a predictable direction.

Exit Ramps, and When Buying Is the Correct Engineering Decision

The strongest argument against this article is often right. A team that builds its own base and rebuilds it quarterly is worse off than one pulling from a vendor whose entire business is rebuilding those images more often than you ever will. Sovereignty that degrades into staleness is just a slower supply chain with a better story. If your sustainable cadence is slower than your risk needs, buy.

Buy when you need FIPS-validated or STIG-hardened variants and have no appetite to maintain that surface; when a contractual patch SLA is a procurement requirement you cannot satisfy internally; when a subscription costs less than the engineer-fraction it replaces; or when the platform team is already the constraint on everything else. Same build-versus-buy reasoning we apply to sovereign infrastructure, one layer lower. What is not negotiable either way is reversibility, so demand three things:

  1. The image definition is source you hold. A declaration you can build — apko YAML, a Dockerfile, a Nix expression — under a licence that survives a commercial dispute. Exactly the ramp Bitnami left open: the source remains on GitHub under Apache 2.0 and the images can be built from it.
  2. The artefact is standard OCI, mirrored into your own registry. Not pulled live from the vendor at deploy time. A cluster that cannot start without reaching somebody else's registry does not have a base image; it has an API call with a filesystem attached.
  3. Signature and SBOM verify with tools that are not the vendor's. cosign verify against a key you hold, and an SBOM your own scanner reads. Provenance checkable only with the supplier's tooling is trust, not evidence.

Hold those three and the decision stays reversible: buy now and build later when the cadence becomes affordable, or the reverse. That matters more than either answer, because the answer changes — Bitnami's boundary moved in 2025, and the four vendors above sit on four different lines today. The teams that came through comfortably never let a base image become a dependency they could not rebuild.

§FAQ/Common questions

Frequently asked

What does distroless actually mean?

The distroless project defines its images as containing only your application and its runtime dependencies, with no package managers, shells, or any other programs you would expect in a standard Linux distribution. That is a statement about contents, not vulnerabilities: fewer packages means fewer that can be vulnerable, not zero. Practically: an entrypoint must be given in vector form because there is no shell to prefix it with, debugging needs the parallel :debug image set that provides a busybox shell, and the non-root identity is UID 65532 from the project's own build variables.

Did Bitnami's free container images go away in 2025?

Not entirely, which is Bitnami's own wording. The 2025 announcement moved all existing container images, including older and versioned tags, from docker.io/bitnami to a docker.io/bitnamilegacy archive as of 28 August 2025 — an archive that receives no updates or support but is still publicly pullable, so those images are frozen rather than paywalled. A limited hardened subset stayed free on the latest tag for development use, while production-ready enterprise containers and Helm charts moved under the paid Bitnami Secure Images offering. Read on 2026-08-14, docker.io/bitnami is still active under the display name Bitnami Secure Images and bitnami/nginx:latest was pushed that same day.

How do I verify that my base image build is actually reproducible?

Do not take the tool's word for it. apko's README claims bit-for-bit reproducibility, but the check that matters runs on your package set and your runners: produce a lockfile with apko lock, pin SOURCE_DATE_EPOCH, build twice from that lockfile into separate directories, and hash the archive each build writes, failing CI on divergence. When it diverges, diff the two SBOMs first — a package version has almost always moved. On the Dockerfile path remember there are two knobs: BuildKit has consumed SOURCE_DATE_EPOCH since 0.11, but rewriting the timestamps of files inside the image additionally requires the exporter option rewrite-timestamp=true, available since BuildKit v0.13.

Can Kyverno enforce that images are built FROM our own base image?

Not by ancestry. Kyverno's documented common attributes for a verifyImages rule are type, imageReferences, skipImageReferences, required, mutateDigest, verifyDigest, repository and imageRegistryCredentials, plus nested attestors and attestations lists. Across that documented set, at admission time, nothing inspects a Dockerfile FROM line. You can enforce that images come from your registry and carry a signature you can verify, which is a strong control, but an image built from an unrelated base, pushed to your registry and signed with your key, satisfies it. Lineage has to be enforced in the build system and the registry, where it is actually visible.

When is buying hardened base images the right decision?

When the rebuild cadence you can honestly sustain is slower than the cadence your risk profile needs — a self-built base rebuilt quarterly is worse than a vendor image rebuilt far more often. Also when you need FIPS-validated or STIG-hardened variants you do not want to maintain, when a contractual patch SLA is a procurement requirement, or when your image count is small enough that a subscription costs less than a fraction of an engineer. The category is not binary: read on 2026-08-14, Chainguard, Bitnami, Docker Hardened Images and Minimus each draw the free/paid line in a different place. Whichever you choose, keep the exit ramp: source you can build, standard OCI artefacts in your own registry, and signatures verifiable without the vendor's tooling.

distrolesscontainer base image provenancedistroless base image self builtreproducible container base image buildapko base image buildcontainer image signing provenance attestation

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.