Ship smaller, faster and safer JVM apps with custom JREs
21 Aug 2025Far 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?
- Size: A full JDK image can be 300â400 MB. A
jlinkruntime for a typical REST service often lands in the 30â80 MB range. Thatâs a big deal for CI/CD speed and registry egress. - Startup & Memory: Fewer modules to scan and fewer classes to load. Itâs not a silver bullet, but youâll often see a small bump in startup time and memory consumption.
- Security: Smaller runtime = smaller attack surface. If you donât ship
jdk.scripting.nashorn(RIP), no one can abuse it. Also fewer CVEs to track. - Determinism:
jlinkcreates a frozen runtime image you can checksum. No âit works on my machineâ because of system JRE differences. - Clarity: You learn what your app truly depends on (and sometimes discover surprising transitive deps).
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:
- Starting with Java 9, the JDK itself became modular. The classic monolithic âJREâ concept became less compelling.
jlinkempowers you to produce application-specific runtimes, which are usually smaller and safer than a generic JRE.- Some vendors still publish JRE-flavored packages (e.g., Temurin offers JDK and JRE builds), but those packages are mostly for testing or non-production use.
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:
- 30â70% smaller images
- Faster pulls & deploys
- Slightly quicker startup and memory c
- Reduced CVE surface
- More predictable runtime behavior
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.
jlink â assemble the runtime
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:
- For Gradle, replace the Maven build with
./gradlew shadowJarand change the JAR path. - If your app relies on native libraries (e.g., Netty epoll, font rendering, or JNI), start from
debian:bookworm-sliminstead of distroless to ensure needed system libs exist â or copy them explicitly. - If you prefer Alpine, use musl-based OpenJDK images end-to-end. Mixing glibc and musl builds will hurt.
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:
- Start large, then trim: begin with the jdeps set and add modules as runtime errors point them out.
- Know the usual suspects:
- JDBC drivers â
java.sql - HTTP clients â
java.net.http - XML / JAXB (legacy) â
java.xml/jdk.xml.dom - SSL/TLS â
java.security.sasl,jdk.crypto.ec(for ECDSA)
- JDBC drivers â
- Unit/integration tests in CI against the custom runtime image to catch missing modules early.
- Use
--add-modules ALL-SYSTEMin a dev image if youâre exploring, then narrow down.
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
- Target the LTS you deploy (17 or 21 today for most teams) and set âmulti-release accordingly when running jdeps.
- Compress your runtime (
--compress=2) and strip debug symbols for production. - Pin the exact JDK base image in Docker (e.g.,
eclipse-temurin:21.0.4_7-jdk-jammy) for reproducibility. - Pair
jlinkwith class data sharing (CDS) if youâre squeezing startup; you can generate CDS archives for your app classes as well. - For multi-service repos, cache the
jlinkoutput per service; only rebuild when jdeps output changes. - Donât over-optimize: if youâre shipping native libs or fonts or running headless services that still need
java.desktop, accept the extra module and move on.
When not to use jlink
- Fat containers with everything baked in (e.g., youâre fine shipping a full JDK, and image size/startup donât matter). Sure, thatâs legit in internal infra.
- Rapid prototyping: use a standard JDK image to move fast, and introduce
jlinkwhen the app stabilizes. - Complex native dependencies that make the base OS and runtime coupling sensitiveâsolve the OS packaging first, then
jlink.
TL;DR (but please donât TL;DR)
- Since Java 9, you can and should build custom runtimes.
- Use
jdepsto discover modules andjlinkto assemble a minimal, reproducible runtime. - Bake that runtime into your final Docker image and ship only what your app needs.
- And yes, thatâs why the classic âdownloadable JREâ faded away: the platform expects you to compose your own.
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.