Distroless container images

Three families of base images published on GHCR: a trimmed Java runtime, an nginx build with a WAF compiled in, and builder/runtime pairs for Node.js. They all sit on Chainguard Wolfi, run as a non-root user with no shell and no package manager, and rebuild nightly so the base never drifts behind its security patches.

The images

Image Base Contents Pull Platforms
jre-distroless-25 Wolfi glibc-dynamic jlink-trimmed Temurin 25 LTS 46 MB amd64, arm64
jre-distroless-21 Wolfi glibc-dynamic jlink-trimmed Temurin 21 LTS 44 MB amd64, arm64
jre-distroless-17 Wolfi glibc-dynamic jlink-trimmed Temurin 17 LTS 40 MB amd64, arm64
nginx-distroless Chainguard static static nginx + ModSecurity v3 + OWASP CRS 7 MB amd64
nodejs-distroless-24-runtime Wolfi glibc-dynamic node 24 binary + shared libs 41 MB amd64
nodejs-distroless-22-runtime Wolfi glibc-dynamic node 22 binary + shared libs 40 MB amd64
nodejs-distroless-24-builder wolfi-base node, npm, node-gyp, gcc, make, python-3, git 267 MB amd64
nodejs-distroless-22-builder wolfi-base node, npm, node-gyp, gcc, make, python-3, git 267 MB amd64

Pull figures are compressed layer totals for the latest amd64 manifest. Every build also publishes an immutable <git-sha>-<DDMMYYYY> tag: pin that in production, not latest. Full URIs are in the image catalog; sources are in the repositories.

Why the base decides the CVE count

Most "minimal" images still inherit a distribution's libc and toolchain packages, and those carry findings a scanner will report forever because upstream has no fix pending. Even the smallest Debian-based distroless variants sit on roughly seventeen permanently unfixed glibc and gcc advisories, so those images can never scan clean no matter how little you add on top.

A JRE does not need that much. libstdc++ is statically linked into HotSpot and zlib ships inside Temurin, so the runtime only needs glibc. Putting it on Chainguard's glibc-only distroless base, which is patched within days, is what takes the scan result to zero CVEs at every severity rather than merely "few".

The same reasoning drives the other two: nginx is compiled statically with ModSecurity and the OWASP Core Rule Set so it can run on a base that contains nothing at all, and Node.js is split into a full-toolchain builder and a runtime holding only the node binary and its shared libraries.

How the guarantee is kept

  • Nightly rebuilds

    A scheduled GitHub Actions job rebuilds every image daily, so latest is at most a day behind the newest Wolfi and Temurin patches.

  • A scan that fails the build

    Trivy runs under a strict gate: any CVE at any severity, including ones with no fix available, stops the publish. Scanning that only reports is not a gate.

  • Non-root, no shell

    Nothing to exec into and no package manager to pull tools with. nginx runs as uid 65532 on port 8080 and is happy with a read-only root filesystem.

  • Signed provenance

    Builds publish an OCI image index with attestation manifests attached, so what was scanned is traceable to what runs.

Using them

Java

Copy the jar onto the runtime; there is no build stage to strip out.

FROM ghcr.io/morningstar-sudo/jre-distroless-21:latest
COPY target/app.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Node.js

Compile with the builder, ship the runtime.

FROM ghcr.io/morningstar-sudo/nodejs-distroless-24-builder:latest AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

FROM ghcr.io/morningstar-sudo/nodejs-distroless-24-runtime:latest
COPY --from=build /app /app
CMD ["server.js"]

nginx

The WAF is a runtime switch, not a separate image: a small static entrypoint picks the ModSecurity config when IS_MS_ON is set.

# WAF off
docker run --rm -p 8080:8080 --read-only --tmpfs /tmp \
  ghcr.io/morningstar-sudo/nginx-distroless:latest

# WAF on: ModSecurity v3 with the OWASP Core Rule Set
docker run --rm -p 8080:8080 --read-only --tmpfs /tmp \
  -e IS_MS_ON=true ghcr.io/morningstar-sudo/nginx-distroless:latest