javadockercontainer-securitycve

7 Java 21 Base Images Compared: 3 Are Genuinely Clean, the Smallest Cannot Be Fixed

Share X in
7 Java 21 Base Images Compared: 3 Are Genuinely Clean, the Smallest Cannot Be Fixed

Container Security · Java 21 · Base Image Selection

We needed to pick a Java 21 base image for a Spring Boot service. So we built the application once, dropped that artefact into six candidate runtimes, scanned all of them, started all of them, and ranked them. Three came back with zero findings at every severity. The smallest image in the set is the only one we cannot fix.

1. The brief

A base image decision looks trivial and is not. The candidates all run Java 21, all start the application, and all pass a smoke test. The differences only appear once you scan them and compare what is left.

We scored every candidate on five things, in this order of weight:

  • Vulnerabilities, split by layer. Findings in the operating system are the base image’s fault and are the base image’s responsibility to fix. Findings inside the application JAR are ours, and are identical in every candidate.
  • Fixability. A finding with no published fix is permanent. That is a different problem from a finding an apt upgrade clears, and it should be weighted accordingly.
  • Size actually transferred. Registry compressed size, not the number docker images prints locally.
  • Attack surface. Does it ship a shell? A package manager? Can an attacker reach a debugger?
  • Support. Who ships security patches, and how fast.

Why the layer split is not optional. Every candidate carries the same app.jar. If you score a base image on a raw CVE total, you are partly scoring your own dependency tree — and the candidate that happens to pair with the newest base layer looks artificially worse. Separating the layers first is what makes the ranking mean something.

2. The candidates

Seven base images, chosen to cover the realistic options for a Java 21 service: the three major distributions’ own images, Google’s distroless line, Red Hat’s two UBI variants plus its hardened Hummingbird build, and one widely recommended minimal image we could not use.

#CandidateBase image referenceVendorWhy it was in the running
1Google Distrolessgcr.io/distroless/java21-debian13:nonrootGoogleThe reference point for “minimal Java”. Everybody benchmarks against it.
2Red Hat Hummingbirdregistry.access.redhat.com/hi/openjdk:21-runtimeRed HatShell-less Java built for hardened use, from the UBI family.
3Eclipse Temurin Alpineeclipse-temurin:21.0.12.1_1-jre-alpineEclipseThe default JRE most Java projects already use.
4Amazon Corretto Alpineamazoncorretto:21-alpine3.24AmazonSecond mainstream Alpine JRE, for cross-checking vendor effect.
5UBI 9 fullregistry.access.redhat.com/ubi9/ubi:9.5 + JRERed HatThe supported enterprise default, where a shell and dnf are often mandatory.
6UBI 9 Microregistry.access.redhat.com/ubi9/ubi-micro:9.5 + JRERed HatThe “micro” option — tests whether the name means distroless.
7Chainguard jrecgr.dev/chainguard/jre:latestChainguardFrequently recommended as the cleanest option. Included to test that claim.

3. How we compared

Three rules, applied to every candidate, so the only variable is the base image:

  • One build. A single Maven stage produced one app.jar, and every runtime copies that same artefact. Tags suffixed cvefix were built before the dependency pins in §9 were applied; cvefix2 and cvefix3 were built after. Nothing is rebuilt per runtime except to re-run the pins.
  • Layer-split scanning. Every image was scanned with Trivy and the results separated into OS packages and application packages, because only the OS column is attributable to the base image.
  • Boot test. Every image was started, actuator polled until it reported UP, and /petclinic/api/vets checked for HTTP 200. A clean image that will not boot is not a candidate.

The application is spring-petclinic-rest 4.0.2 — Spring Boot 4.1.1, Java 21, 107 nested JARs in a 69 MB fat JAR. It carries four dependency findings of its own, addressed separately in §9.

A tooling trap that will corrupt your comparison. Trivy reports Debian-family results with a result class of os-pkgs/debian and a result type of debian — not os. A script that buckets on .Type == "os" silently files operating-system findings into the application bucket, and the ranking inverts: the cleanest images look vulnerable and the dirtiest looks acceptable. Match on the Class prefix os-pkgs/ instead.

4. Results

The full comparison. OS columns are the base image’s responsibility and are what the ranking is built on. App shows the dependency state of the JAR in that specific image: tags suffixed cvefix predate the Spring Retry pin and carry one finding, cvefix2 and cvefix3 are built from the pinned POM and carry none, and the UBI Micro control deliberately keeps the original unfixed JAR.

CandidateOS COS HOS MOS LOS ?OS totalAppAllRegistryLocalShellUser
Red Hat Hummingbirdcvefix200000000145.5 MB329 MBnone65532
Amazon Corretto Alpinecvefix200000000220.5 MB397 MBBusyBox65532
Eclipse Temurin Alpinecvefix3 — see post 200000000135.9 MB288 MBBusyBox65532
Google Distrolesscvefix083520164165123.6 MB264 MBnone65532
UBI 9 Microcontrol — unfixed JAR08692009718115140.5 MB322 MBBusyBox1001
UBI 9 fullcvefix202527627405750575337.7 MB778 MBBash + DNF1001
Chainguard jrenot scored — see §7. Tag is Java 27 and the Java 21 tags could not be pulled anonymously.—————

C/H/M/L = CRITICAL, HIGH, MEDIUM, LOW. OS ? counts findings Trivy rates UNKNOWN — it has no CVSS vector yet, not because it is ignorable. Registry = sum of compressed layer sizes from the manifest, which is what a pull actually transfers; Local = docker images uncompressed. The UBI Micro row is a deliberately unfixed control: its 18 application findings are the original JAR, explained in §9, and its OS column is the fair comparison. Every scored row was boot-tested to actuator UP and HTTP 200.

5. OS findings compared

The same data as total OS findings, which is the number a policy gate would actually threshold on.

Red Hat Hummingbird

0

Amazon Corretto Alpine

0

Eclipse Temurin Alpine

1

Google Distroless

64

UBI 9 Micro

97

UBI 9 full

894

The gap between the clean tier and the rest is the whole decision. Three candidates are at zero and the next is at 64. A policy gate that fails a build on any HIGH finding passes Hummingbird, Temurin and Corretto and fails the rest — and there is no version of that gate you can tune that puts Google Distroless in the passing column.

6. The shortlist

Three candidates return zero findings at every severity. They are not interchangeable, and the differences between them are the actual decision: one has no shell at all, one is the smallest but ships BusyBox, and one is 75 MB heavier than the recommendation for the same shell.

7 CANDIDATES COMPARED GoogleDistroless HummingbirdRed Hat TemurinEclipse CorrettoAmazon UBI 9full UBI MicroRed Hat Chainguardunusable — Java 27 filter: zero findings at every severity 3 FULLY CLEAN Red Hat Hummingbird 0 findings · no shell at all 145.5 MB · uid 65532 · nothing to patch Eclipse Temurin Alpine 0 findings after apk upgrade 135.9 MB · BusyBox · smallest clean Amazon Corretto Alpine 0 findings — every severity 220.5 MB · BusyBox shell present tie-break: attack surface, then size RECOMMENDED quay.io/hellodk/base-image:hummingbird-4.0.2-java21-cvefix2 Zero findings · no shell · 145.5 MB compressed — the only clean image in the set with no shell, and 9.6 MB of transfer is a cheap price for keeping it

Seven candidates, three fully clean, one recommended. All three score zero findings; the tie is broken on shell, then size.

Red Hat Hummingbird — zero findings and no shell

Zero findings in the OS layer and in the application layer, at every severity including UNKNOWN. It also ships no shell at all, and 145.5 MB compressed. Two other images match it on vulnerabilities — one is 9.6 MB smaller, one is 75.0 MB larger — and both ship BusyBox. Hummingbird is the only candidate in the entire set that is simultaneously scan-clean and shell-less.

What “zero” means here, precisely. Hummingbird reports no OS package results at all — there is no package manager and no /etc/os-release package list to scan, so there is nothing for Trivy to find. That is the intended consequence of the design, not a scanner failure: the attack surface that dnf would manage has been removed rather than patched.

Naming warning. The Java 21 runtime is hi/openjdk:21-runtime. Both hi/java and hi/openjdk-21 return not found, and neither is an alias that resolves. Verified on the running image: no /bin/sh, UID 65532, JAVA_HOME=/usr/lib/jvm/java-21-openjdk, and Spring Boot logs started by ? because the image has no /etc/passwd entry for that UID.

Amazon Corretto Alpine — equally clean, 79 MB heavier

Also zero CRITICAL, HIGH, MEDIUM, LOW and UNKNOWN in both layers. The difference is cost and surface: 220.5 MB compressed against Hummingbird’s 145.5 MB, and a BusyBox shell that hands an attacker with code execution an interactive shell. Choose it if you want a maintained Alpine JRE and a shell for debugging.

Eclipse Temurin Alpine — smallest clean image, once libpng was upgraded

At cvefix2 this image carried one OS finding rated UNKNOWN: CVE-2026-46675 in libpng, a use-after-free in png_read_end after incomplete zTXt, iTXt or iCCP decompression. It was not unfixable — a fix existed at libpng 1.6.59-r0, and the base tag simply shipped an older Alpine package snapshot. Adding RUN apk upgrade --no-cache to the Dockerfile cleared it and pulled openssl, libssl3, libcrypto3 and libldap forward with it. The rebuilt cvefix3 tag scans at zero in both layers, is the smallest clean image in the set at 135.9 MB, and costs 3.1 MB over cvefix2. It still ships BusyBox.

7. Verdict per candidate

RankCandidateVerdictReasoning
1Red Hat Hummingbird cvefix2RecommendedZero findings in both layers, no shell, 145.5 MB. It is the only clean image in the set without a shell, and that is the whole argument: Temurin is 9.6 MB smaller and Corretto is 75.0 MB larger, but both hand an attacker with code execution an interactive shell.
2Eclipse Temurin Alpine cvefix3Recommended if a shell is acceptableZero findings in both layers after apk upgrade cleared the libpng finding, at 135.9 MB — the smallest clean image in the set. Costs a BusyBox shell for 9.6 MB of transfer.
3Amazon Corretto Alpine cvefix2Clean, but dominatedAlso zero in both layers, with a maintained Alpine JRE. It is 75.0 MB larger than Hummingbird and 84.6 MB larger than Temurin while offering the same shell — strictly worse than both. Pick it for vendor support, not for size or surface.
4Google Distroless cvefixNot recommendedThe smallest image in the whole set at 123.6 MB and shell-less, but carries 8 HIGH findings — libexpat1 ×4 and libuuid1 ×4 — and none has a published fix. It cannot be made clean by any action available to you.
5UBI 9 MicroRejected8 HIGH in the OS layer (pcre2 ×3, pcre2-syntax ×3 with no fix; libacl and libcap fixable), and it ships a shell — so “micro” did not deliver distroless behaviour. Also the only image still carrying the unfixed JAR.
6UBI 9 full cvefix2Rejected575 OS findings including 25 HIGH, at 337.7 MB compressed with a shell and a package manager. Remediating it cleared 320 findings and 70 of the 95 HIGH, but it remains the worst image in the set by a wide margin. Justified only where an in-container dnf is a hard requirement.
—Chainguard jreUnusablePulls anonymously and scans at zero CVEs, but reports openjdk version “27”. Every Java 21 tag attempt failed anonymously and the directory marks those tags access-restricted. A clean image on the wrong Java version, behind a request form, is not a candidate.

8. Recommendation

Three candidates are fully clean. Pick by constraint:

If your priority is…UseWhy
Clean, shell-lesshummingbird-4.0.2-java21-cvefix2Zero findings in both layers, no shell, 145.5 MB compressed. This is the default choice.
Smallest clean image, shell acceptabletemurin-4.0.2-java21-cvefix3Zero findings in both layers at 135.9 MB, 9.6 MB under the recommendation. Gives up the shell-less property. The cvefix2 tag is the same image before the libpng fix and still carries one UNKNOWN finding.
Clean with a vendor-supported Alpine JREcorretto-4.0.2-java21-cvefix2Also zero in both layers. Costs 75.0 MB more than Hummingbird and 84.6 MB more than Temurin for the same BusyBox shell.
Smallest image, accepting unfixable findingsgoogle-4.0.2-java21-cvefix123.6 MB compressed, but 8 HIGH with no fix. Only rational if size outweighs permanence.

All images are published under a single repository whose name describes what it holds rather than where it came from. Every digest below was read back from the registry after the push and matches the local build exactly.

TagRegistryOS totalApp totalDigest
hummingbird-4.0.2-java21-cvefix2145.5 MB005e123f11…
temurin-4.0.2-java21-cvefix3135.9 MB00b924ecd3…
corretto-4.0.2-java21-cvefix2220.5 MB000185d185…
temurin-4.0.2-java21-cvefix2132.8 MB10948ba6b9…
hummingbird-4.0.2-java21-cvefix145.5 MB01a07d859f…
temurin-4.0.2-java21-cvefix132.8 MB1116c6a897…
corretto-4.0.2-java21-cvefix220.5 MB0161af6ed8…
google-4.0.2-java21-cvefix123.6 MB641cd50ed9b…
google-4.0.2-java21-fixed †123.7 MB670ba8b0af3…
ubi-micro-4.0.2-java21140.5 MB97187888370f…
ubi-micro-4.0.2-java21-fixed †147.3 MB56069ee63e7…
ubi9-4.0.2-java21-cvefix2337.7 MB5750fdc4f9ed…
hummingbird-4.0.2-java21-base ‡102.7 MB0n/a67d22d2c…
temurin-4.0.2-java21-base ‡178.2 MB0n/a24d9ae63…
corretto-4.0.2-java21-base ‡158.4 MB0n/aa87313c9…

quay.io/hellodk/base-image. Fifteen tags, six runtime families. The cvefix and cvefix2 pairs are the same runtime rebuilt after the final application-layer pin — the suffix is the honest label for that rebuild, and the side-by-side numbers in §9 are why it exists. ubi-micro-4.0.2-java21 is published deliberately as the unfixed control: it is the only micro image still containing the original JAR, which is what makes that section reproducible.

‡ Runtime-only base images, added on 2026-10-04. Every other row in this table is an application image: it carries the JAR, which is what makes the App column meaningful. The three base tags carry no application at all, so App reads n/a rather than 0 — there is nothing to scan. They are the same three Alpine-flavoured runtimes that reached zero above, rebuilt from the pristine vendor base with WORKDIR /app and ENTRYPOINT [] and no JAR. At 102.7 MB the Hummingbird base is 42.8 MB smaller than the same runtime with the application attached. All three run as UID 65532 and were re-pulled from the registry and re-scanned after the push.

† Added on 2026-10-03 and measured with that day’s vulnerability database, while every unmarked row was measured on 2026-10-02. Trivy gained four entries in that interval which apply to versions the Google control already shipped, so its same-day figure is 68 OS + 1 app, not 64 + 1. Compared like-for-like on 2026-10-03 the Google move is 69 → 67, and a per-CVE package-version diff confirms the build changed only libpng16-16t64 (deb13u5 → deb13u6) and the rebuilt spring-retry pin — no regression. Comparing a scan taken this week against one taken last week measures the database, not the image.

9. What no base image fixes

Layer-splitting the scan is what makes the ranking above fair, and it also exposed something the ranking would otherwise hide. The application layer carried 13 HIGH/CRITICAL findings plus one MEDIUM that were present in every image built before the fixes, and that no base image could have removed:

FindingLibraryFixed by
3 CRITICALorg.apache.tomcat.embed:tomcat-embed-core 11.0.24<tomcat.version>11.0.25
3 HIGHcom.fasterxml.jackson.core:jackson-databindjackson-bom 2.21.7 for the unmanaged 2.x train
2 HIGHcom.fasterxml.jackson.core:jackson-core
3 HIGHtools.jackson.core:jackson-databind<jackson-bom.version>3.1.7
2 HIGHtools.jackson.core:jackson-core
1 MEDIUMorg.springframework.retry:spring-retry 1.0.3 — CVE-2026-41710Explicit spring-retry 2.0.13 pin

The Jackson case is the instructive one. Spring Boot 4 runs the 2.x and 3.x Jackson trains in parallel and its parent manages only the newer one, so a routine version bump leaves the legacy train exactly where it was:

<dependencyManagement>
  <dependencies>
    <!-- Pin the legacy FasterXML Jackson 2.x train, unmanaged by the Boot 4 parent -->
    <dependency>
      <groupId>com.fasterxml.jackson</groupId>
      <artifactId>jackson-bom</artifactId>
      <version>2.21.7</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.retry</groupId>
      <artifactId>spring-retry</artifactId>
      <version>2.0.13</version>
    </dependency>
  </dependencies>
</dependencyManagement>

The root cause is a 2019 pin still in the upstream pom.xml, which drags in spring-retry 1.0.3 through spring-data-jdbc-core:1.2.1.RELEASE:

<spring-data-jdbc.version>1.2.1.RELEASE</spring-data-jdbc.version>

Measured across builds: 18 application-layer findings became 1, then 0. The first three pins cleared every HIGH and CRITICAL; the fourth was missed on the first pass, which is why a second tag exists. This is also the reason the ranking had to be redone: on the first pass Hummingbird and Temurin each still carried the MEDIUM, and calling them “clean” would have been wrong. A remediation is not finished until the scan says so.

10. What we could not fix

Google Distroless — 8 HIGH, no fix published

Every one of Google Distroless’s HIGH findings reports no available fixed version:

  • libexpat1 — CVE-2026-66046, CVE-2026-76956, CVE-2026-76957, CVE-2026-93990
  • libuuid1 — CVE-2026-76642, CVE-2026-78408, CVE-2026-78409, CVE-2026-78410

There is no apt upgrade that clears these, because upstream has not shipped the fix. This is the strongest argument in the comparison against ranking on size alone — Google Distroless is the smallest image in the set at 123.6 MB, and it is also the only one that cannot be made clean by any action available to you.

Eclipse Temurin Alpine — one finding at UNKNOWN

Temurin’s single OS finding is CVE-2026-46675 in libpng 1.6.58-r1, rated UNKNOWN because Trivy has no CVSS vector for it yet. A fix is published, so this is recoverable and does not disqualify the image. We still do not count it as clean: an unscored CVE is an open question, and treating UNKNOWN as equivalent to LOW is how a zero-finding claim becomes wrong three weeks later.

The same CVE, two families. Google Distroless carries CVE-2026-46675 in libpng16-16t64 as well. Because Google already fails on 8 unfixable HIGH, the shared libpng issue does not change its verdict — but it is worth knowing that this particular finding is common to Debian and Alpine derivatives and is not a defect of either one.

UBI 9 Micro — 8 HIGH, 6 with no fix

Not the same findings as Google’s, which is worth knowing: UBI Micro is RHEL 9 based, so its exposure is pcre2 ×3 and pcre2-syntax ×3 with no fix, plus libacl and libcap where a fix does exist. Two of eight are recoverable; six are not.

UBI 9 full — 95 HIGH before remediation, 25 after

The worst image in the set, and the only candidate that ships a package manager capable of fixing itself. Its original 95 HIGH findings were dominated by packages a Java service never calls:

  • python3 and python3-libs — 9 each
  • libevent, openssl, openssl-libs — 6 each
  • expat — 5, plus 22 further packages

Having dnf on board makes remediation possible where it is not for Google or UBI Micro. A full dnf upgrade cleared 320 of 895 findings and 70 of the 95 HIGH, and moved the base from RHEL 9.5 to 9.8. That is a large improvement, and it still does not rescue the image: 575 findings at 337.7 MB compressed leaves it the worst trade in the set.

11. Lessons

  • Compare on the OS layer only. The application JAR is identical across candidates, so including it makes the ranking partly a measurement of your own dependency tree.
  • Weight fixability above count. Eight findings you can patch are a queue. Eight with no published fix are a permanent property of that image.
  • Smallest is not best. The smallest candidate here — Google Distroless at 123.6 MB — is the only one that cannot be remediated. The recommended image is 21.9 MB larger compressed and fully clean.
  • Rank on the final state, not the first one. Our first pass put Hummingbird and Temurin in the clean column while each still carried a MEDIUM. Rebuilding after the last dependency pin is what turned a “probably clean” recommendation into a measured zero.
  • “Micro” does not mean “distroless”. UBI Micro shipped a working shell, failing the test that Hummingbird and Google pass.
  • A clean image on the wrong version is not clean. Chainguard’s zero-CVE Java 27 image cannot run this workload, and its Java 21 tags are gated.
  • Quote registry size, not local size. A “280 MB” Temurin image transfers as 132.8 MB; a “778 MB” UBI image transfers as 337.7 MB. Sum the compressed layer sizes from the manifest — the number docker images prints is not it.
  • Treat UNKNOWN as a finding — then go and fix it. Two candidates carried CVE-2026-46675 in libpng rated UNKNOWN, which kept one of them out of the clean tier. It turned out to be a stale package snapshot, not an unpatchable CVE: one apk upgrade cleared it for 3.1 MB. An unscored severity is a reason to investigate, not a reason to accept the finding.
  • Unmanaged dependency trains fail quietly. Spring Boot 4 manages Jackson 3 and not Jackson 2, so the legacy train stayed vulnerable through a routine upgrade.
  • A repository name is part of your supply chain. One image in this set was published under a repository name that misdescribed its contents. Pin by digest.
  • Boot-test every candidate. A clean image that will not start is not a candidate, and cp -aL dereferencing a symlinked JVM config was needed to get one of them running at all.

12. Reproduce

One Maven build, one app.jar, six runtimes plus one unfixed control:

FROM maven:3.9.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B -DskipTests package

# per runtime, the only change:
COPY --from=build /src/target/*.jar /app/app.jar

Runtime bases:

FROM gcr.io/distroless/java21-debian13:nonroot
FROM registry.access.redhat.com/hi/openjdk:21-runtime
FROM docker.io/library/eclipse-temurin:21.0.12.1_1-jre-alpine
FROM docker.io/library/amazoncorretto:21-alpine3.24
FROM registry.access.redhat.com/ubi9/ubi:9.5
FROM registry.access.redhat.com/ubi9/ubi-micro:9.5

Split the scan by layer. This is the step that makes the comparison valid, and the Class prefix is what makes it correct:

trivy image --scanners vuln --format json <image> \
  | jq '{
        os:  [.Results[] | select(.Class | startswith("os-pkgs/"))
                | .Vulnerabilities[]? | .Severity] | group_by(.) | map({(.[0]): length}) | add,
        app: [.Results[] | select(.Class | startswith("jar"))
                | .Vulnerabilities[]? | .Severity] | group_by(.) | map({(.[0]): length}) | add
      }'

Boot-test, and confirm what you actually shipped — repository names lie, digests do not:

docker run --rm -d -p 8080:8080 <image> && \
  until curl -fsS localhost:8080/actuator/health | grep -q '"status":"UP"'; do sleep 2; done

docker inspect --format '{{json .Config.Labels}}' <image> | jq
docker manifest inspect quay.io/hellodk/base-image:<tag> | jq -r '.config.digest'

Tooling. All results are Trivy 0.70.0. Two things about it will quietly corrupt a comparison: Debian results are typed debian rather than os, and OS findings carry a FixedVersion of empty string rather than null when no fix exists — so a check for != null passes on unfixable findings.

13. Sources

GoogleContainerTools / distroless — published images and Debian 13 base Spring PetClinic REST — upstream pom.xml with the spring-data-jdbc pin Red Hat Catalog — Hummingbird hi/openjdk:21-runtime Red Hat Catalog — UBI 9 Micro Chainguard — JRE tag list and access status Aqua Security Trivy — scanner used for every figure here

Share this post X in

Enjoyed this post?

Get the next one in your inbox — only when I ship something worth reading.

Newsletter form not configured.

Or follow on Substack for the newsletter.

Comments via GitHub Discussions

Comments not configured. Set GISCUS env vars to enable.