Ship smaller, faster and safer JVM apps with custom JREs

Far too few teams are using custom Java runtimes. Since Java 9 introduced the Java Platform Module System (JPMS), we’ve had first-class tooling — jdeps and jlink — to trim the JRE down to only what your app actually needs. That means smaller Docker images, faster cold starts, and a tighter attack surface. If you’re still copying a full JDK or a generic JRE into production, you’re leaving wins on the table.

Why bother with custom JREs?

And yes, this has been there since Java 9. We should all be using it by now.


But wait — where did the JRE go?

Rhetorical question time: Why doesn’t the Java team ship a generic, one-size-fits-all JRE anymore (as of the Java 9 era)? The short version: because of this. With JPMS and jlink, the platform pivoted from “download a big JRE” to “compose the runtime your app needs.”

A bit more nuance:

So yes, the disappearance (or de-emphasis) of the old bundled JRE is because you’re expected to build your own minimal runtime for production.


The payoff

Move a service from “full JRE in production” to “custom jlink runtime” and you typically get:

None of this requires heroic refactors—just a couple of Docker stages and two standard tools.


The two tools you need

jdeps — discover module dependencies

jdeps analyzes your application’s JAR(s) and prints the exact set of JDK modules you need.

# Build your app first (e.g., ./gradlew shadowJar or mvn package)
# Then analyze the fat jar (or a dir of jars)
jdeps \
  --multi-release 17 \
  --ignore-missing-deps \
  --print-module-deps \
  build/libs/app-all.jar

That prints something like:

java.base,java.logging,java.net.http,java.sql

If you’re on Java 21, swap the –multi-release accordingly. Add –class-path for any non-JDK deps if needed.

Feed those modules to jlink and it will produce a self-contained runtime image:

jlink \
  --add-modules java.base,java.logging,java.net.http,java.sql \
  --strip-debug \
  --no-man-pages \
  --no-header-files \
  --compress=2 \
  --output build/custom-java

The build/custom-java directory is your custom “JRE” (technically a runtime image) with bin/java inside. Ship that next to your app and you’re done.


A production-grade Dockerfile (multi-stage)

Here’s a clean pattern that builds the app, computes the minimal modules, bakes a custom runtime, then ships only the runtime + app.

# ---- 1) Build stage: compile app ----
FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /workspace

# Copy only build files first for better layer caching (Maven example)
COPY pom.xml ./
COPY src ./src

# Build fat jar (adjust for Gradle if you prefer)
RUN mvn -q -DskipTests package

# ---- 2) Analyze stage: compute modules with jdeps ----
FROM eclipse-temurin:21-jdk-jammy AS jdeps
WORKDIR /jdeps
COPY --from=build /workspace/target/*.jar app.jar
# Figure out exactly which JDK modules we need
RUN DEPS=$(jdeps --multi-release 21 --ignore-missing-deps --print-module-deps app.jar) \
&& echo $DEPS > modules.txt

# ---- 3) JLink stage: assemble minimal runtime ----
FROM eclipse-temurin:21-jdk-jammy AS jlink
WORKDIR /jlink
COPY --from=jdeps /jdeps/app.jar /jlink/app.jar
COPY --from=jdeps /jdeps/modules.txt /jlink/modules.txt
# Build the custom runtime
RUN jlink \
--add-modules $(cat modules.txt) \
--strip-debug --no-man-pages --no-header-files --compress=2 \
--output /jre

# ---- 4) Runtime stage: tiny final image ----
# If you don't use native libs, a distroless base is great. Otherwise use debian:bookworm-slim.
FROM gcr.io/distroless/base-debian12:nonroot
WORKDIR /app

# Copy app and the custom Java runtime
COPY --from=build  /workspace/target/*.jar /app/app.jar
COPY --from=jlink  /jre /opt/java

# Non-root by default with distroless; override entrypoint
ENTRYPOINT ["/opt/java/bin/java","-XX:+UseContainerSupport","-jar","/app/app.jar"]

Notes:


Handling tricky cases (reflection, ServiceLoader, dynamic features)

jdeps is static analysis. If you load classes dynamically (reflection, ServiceLoader), jdeps might miss some modules. A few strategies:


Make it repeatable

Add a tiny script to your repo so devs and CI can reproduce the runtime:

#!/usr/bin/env bash
set -euo pipefail

JAR=${1:-build/libs/app-all.jar}
JAVA_VERSION=${2:-21}
OUT_DIR=${3:-build/custom-java}

DEPS=$(jdeps --multi-release $JAVA_VERSION --ignore-missing-deps --print-module-deps "$JAR")
echo "Modules: $DEPS"

jlink \
--add-modules "$DEPS" \
--strip-debug --no-man-pages --no-header-files --compress=2 \
--output "$OUT_DIR"

"$OUT_DIR/bin/java" -version

Call it from your Docker build or CI pipeline to ensure the runtime is always derived from the current app.


Opinionated best practices



TL;DR (but please don’t TL;DR)

If you haven’t tried this on your main service, do it this week. Measure the image size and cold-start delta, share the numbers with your team, and make it the default. The tooling’s been rock-solid for years—time to cash in the benefits.