Most Java Docker images still start from a full JDK base image running as root, with a package manager, a shell, and dozens of OS utilities an attacker can use the moment they land a foothold. Google's distroless project (GoogleContainerTools/distroless) takes the opposite approach: its gcr.io/distroless/java* images ship no shell, no package manager, and no coreutils at all, leaving only the JVM and the certificate store needed to run a compiled application. That reduction matters for JVM apps specifically because OpenJDK didn't even read cgroup memory and CPU limits correctly until -XX:+UseContainerSupport was enabled by default in JDK 10 (tracked as JDK-8196595), which means older container setups were routinely misjudging available heap and getting OOM-killed by the host. Combine a distroless base with the newer JVM container-awareness flags and a non-root UID, and you get an image with a dramatically smaller attack surface and predictable memory behavior under Kubernetes limits. This tutorial walks through the multi-stage Dockerfile pattern, the specific UID distroless expects for Kubernetes runAsNonRoot enforcement, and the JVM flags worth setting explicitly rather than trusting defaults.
Why does a distroless base image reduce Java's attack surface?
A distroless base reduces attack surface because it removes everything an attacker would use after gaining code execution inside the container. A standard eclipse-temurin:17-jdk or openjdk:17 image includes apt, bash, curl, and a full Debian or Alpine userland — tools useful for building software but equally useful for a post-exploitation foothold to pull down a second-stage payload or explore the filesystem. gcr.io/distroless/java17-debian12 strips all of that, keeping only glibc, the JVM runtime, and CA certificates, so there is no shell to pop even if an attacker achieves arbitrary command injection. This isn't a theoretical benefit — GoogleContainerTools maintains distroless specifically as a production runtime base, distinct from the JDK images used only to compile, and the project has separate latest, versioned, and :nonroot tags for java17, java21, and other supported releases documented directly in the project's repository.
How do you structure a multi-stage build for a distroless Java runtime?
You structure it as two stages: a build stage with the full JDK and build tool, and a runtime stage that copies only the compiled artifact onto distroless. The build stage can use eclipse-temurin:21-jdk (or your JDK vendor of choice) with Maven or Gradle installed, running mvn package or ./gradlew build to produce a JAR. The runtime stage then starts fresh FROM gcr.io/distroless/java21-debian12:nonroot and uses COPY --from=build /app/target/app.jar /app/app.jar. None of the build tooling, source code, or dependency cache ends up in the final image — only the JAR and the JVM. This is the same multi-stage pattern Docker's own documentation recommends for compiled languages generally, and for Java it has an outsized effect: a Maven build stage with the full toolchain and dependency tree can be several hundred megabytes, while the resulting distroless runtime layer on top of the JAR itself is typically tens of megabytes, because the JRE-only distroless base carries no build tooling.
Why does the numeric UID 65532 matter more than the nonroot tag name?
It matters because Kubernetes cannot verify a string username, only a numeric UID, when enforcing runAsNonRoot in a pod security context. Distroless's :nonroot image variants default to a nonroot account with UID 65532 and a working directory of /home/nonroot set to mode 0700, as documented in the distroless project's own GitHub issue tracker (issue #443 requests clearer documentation of the nonroot/nobody accounts), and downstream projects have hit the exact numeric-vs-named-user snag in production — see kubebuilder issue #1637 and the cloud-sql-proxy issue #386, both showing the container has runAsNonRoot and image has non-numeric user (nonroot), cannot verify user is non-root error. If your Dockerfile sets USER nonroot and your Kubernetes manifest sets securityContext.runAsNonRoot: true, some clusters will still reject or misreport the pod because Kubernetes checks the UID field, not the resolved username, and a string value doesn't satisfy that check in every kubelet/runtime combination. The reliable fix is to set USER 65532:65532 explicitly in the Dockerfile, and mirror it with runAsUser: 65532 in your pod spec, so the numeric identity is unambiguous end to end rather than relying on name resolution that varies by container runtime.
Which JVM flags actually matter for container-aware memory sizing?
The two that matter most are -XX:+UseContainerSupport, which has been the default since JDK 10, and -XX:MaxRAMPercentage, which sizes the heap relative to the detected container memory limit instead of a fixed value. Before JDK 10, the JVM read /proc/meminfo and saw the host's total memory rather than the cgroup limit assigned to the container, so a JVM in a 512 MB-limited pod could still compute a default heap sized for the host's 64 GB, guaranteeing an OOM-kill under load — the exact problem JDK-8196595 fixed. -Xmx alone doesn't solve the follow-on problem: a fixed heap value baked into a Dockerfile becomes stale the moment the pod's memory limit changes in a Helm chart or Kubernetes manifest. Setting -XX:MaxRAMPercentage=75.0 (with -XX:InitialRAMPercentage and -XX:MinRAMPercentage alongside it, as commonly documented in Baeldung's and Datadog's Java-container guides) lets the JVM recompute heap bounds from whatever cgroup limit is actually in effect at startup, so the same image behaves correctly whether it's deployed with a 512 MB or a 4 GB limit.
What does a hardened distroless Dockerfile look like end to end?
It combines the multi-stage build, the numeric non-root user, and explicit container-awareness flags in the entrypoint. A representative runtime stage looks like:
FROM gcr.io/distroless/java21-debian12:nonroot
COPY --from=build /app/target/app.jar /app/app.jar
USER 65532:65532
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"]
Because distroless has no shell, ENTRYPOINT must use the exec-array form — there is nothing to invoke a shell-form CMD java ... string through, which is itself a small hardening win since it removes shell-based argument injection as a vector entirely. Pin the base image by digest rather than tag in production builds, since :nonroot and version tags are mutable pointers that distroless updates as it rebuilds for upstream patches.
How does this fit into an ongoing container security program?
A hardened Dockerfile is a starting posture, not a permanent one — the JDK, glibc, and any native libraries baked into the distroless layer still accumulate CVEs over time and need to be tracked continuously rather than checked once at build time. Safeguard's container image scanning generates SBOMs across layers for exactly this reason, including FROM scratch and distroless bases where there's no package manager to query locally, and its self-healing containers capability plans and rebuilds an image automatically when a new CVE lands on a component inside it — Safeguard's own telemetry across customer tenants shows a median time-to-heal of 20-45 minutes and a P95 of 2 hours from CVE publication to a patched rollout. For a minimal Java base image, that means the hardening work in this post doesn't have an expiration date baked into the day you built it.