jlink has been sitting in every JDK distribution since Java 9 shipped in September 2017 as part of Project Jigsaw, and most teams running Spring Boot in production have still never invoked it. The default habit is to FROM eclipse-temurin:21-jdk or similar, copy in a fat JAR, and ship — an image that typically lands somewhere between 300MB and 670MB once you count the full JDK, its bundled tools, and the base OS packages underneath it. A custom runtime built with jlink, layered onto something like alpine or a distroless base, commonly comes in at 80–150MB for the same application — a reduction vendors and community benchmarks have repeatedly measured in the 2-5x range, though the exact number depends heavily on which modules your app actually touches. This isn't just a pull-time convenience. Every JDK tool, man page, and unused module you don't ship is one less thing an attacker can find useful after a container compromise, and one less component your SBOM has to track for the next CVE. This post walks through how jlink and its companion tool jdeps work, what they do and don't fix, and how to wire them into a real multi-stage Dockerfile for a Spring Boot service.
What does jlink actually do?
jlink assembles a custom Java runtime image containing only the modules your application needs, instead of shipping the full JDK or a general-purpose JRE. It's a direct product of the Java Platform Module System (JPMS), introduced in Java 9 via JEP 220 and 261, which reorganized the JDK itself into roughly 70+ discrete modules (java.base, java.sql, java.logging, java.net.http, and so on) rather than one monolithic rt.jar. Where a traditional JRE bundles every module whether you use it or not, jlink --add-modules java.base,java.logging,java.sql --output custom-runtime produces a self-contained runtime directory with just those modules, their transitive dependencies, and nothing else — no javac, no jshell, no jconsole, no full internationalization data unless you ask for it. The output is a runnable JRE-equivalent, not a stripped-down JDK; you can java -jar app.jar against it directly. Because this requires the module boundaries JPMS introduced, jlink only works against Java 9+ applications — pre-modular JDK 8 codebases need to at least compile and run cleanly on 9+ before this technique applies.
How do you figure out which modules to include?
You don't guess — you run jdeps against your actual built artifact and let it tell you. jdeps, introduced in Java 8 specifically to prepare the ecosystem for JPMS, performs static analysis of a class or JAR file's bytecode and lists every java.* and jdk.* module it references. Against a Spring Boot fat JAR, the invocation looks like jdeps --multi-release 21 --print-module-deps --ignore-missing-deps target/app.jar, and it prints a comma-separated module list — typically something like java.base,java.desktop,java.instrument,java.management,java.naming,java.net.http,java.security.jgss,java.sql,jdk.unsupported for a fairly ordinary REST service using Spring Data and Micrometer. That string feeds directly into jlink --add-modules. The --ignore-missing-deps flag matters in practice: reflection-heavy frameworks (Spring's classpath scanning, Hibernate's proxies) sometimes reference classes jdeps can't fully resolve statically, and without that flag the analysis can fail outright rather than under-report.
What does a working multi-stage Dockerfile look like?
The pattern is three stages: build the JAR, derive and build the custom runtime, then assemble the final image from just that runtime plus the JAR. A representative Dockerfile:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw -q package -DskipTests
RUN jdeps --multi-release 21 --print-module-deps --ignore-missing-deps target/app.jar > /modules.txt
FROM eclipse-temurin:21-jdk AS jre-build
COPY --from=build /modules.txt /modules.txt
RUN jlink --add-modules $(cat /modules.txt) --strip-debug --no-header-files --no-man-pages --compress=2 --output /custom-jre
FROM debian:12-slim
COPY --from=jre-build /custom-jre /opt/jre
COPY --from=build /app/target/app.jar /app/app.jar
ENV PATH="/opt/jre/bin:$PATH"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
The --strip-debug, --no-header-files, and --no-man-pages flags remove debug symbols, C headers, and documentation that add nothing at runtime; --compress=2 applies zip-level compression to the module image. Swapping debian:12-slim for a distroless Java base or alpine shrinks the final layer further, though Alpine's musl libc has occasionally caused subtle native-library incompatibilities worth testing for.
Does a smaller runtime actually make the image more secure?
Yes, but for a narrower reason than "smaller is safer" — it reduces the number of components with a version number and a CVE history that you have to track, patch, and scan inside the image. A full JDK ships jshell, jconsole, jstack, and a full complement of internationalization and cryptography providers, several of which have carried their own CVEs over the years independent of anything in java.base. Removing tools like jshell also removes a script-execution surface that would otherwise be sitting on disk in a container an attacker could exec into after compromising the running application. This is a genuinely defensible security improvement, but it's an attack-surface reduction on the JDK runtime itself — it has nothing to do with the vulnerability status of your actual dependencies. Log4Shell (CVE-2021-44228, disclosed December 2021), which affected Log4j-core versions 2.0-beta9 through 2.14.1 via JNDI lookups triggering remote class loading, would have been exactly as exploitable inside a jlink-built image as inside a full JDK image, because it lived in an application dependency, not in the JDK modules jlink trims.
Where does jlink fall short, and what still needs a scanner?
jlink shrinks the runtime; it does nothing about the dozens or hundreds of third-party JARs your Spring Boot app pulls in via Maven or Gradle, and that's where the actual majority of exploitable CVEs in Java applications tend to live. A minimal custom JRE with a vulnerable Jackson or Spring Framework version on the classpath is still a vulnerable image — jlink has no visibility into your dependency tree at all, only into which JDK modules your compiled bytecode touches. Teams that treat a smaller base image as a substitute for dependency scanning are solving the wrong half of the problem. The two need to run together: jlink for JDK-level attack surface, and SBOM generation plus SCA scanning for the transitive dependency graph — checking not just what's pinned in your pom.xml but what's actually resolved and shipped in the final layer.
How Safeguard Helps
Safeguard's container image scanning treats a jlink-built custom runtime the same way it treats any other base image: it scans every layer to generate a full SBOM and identify known vulnerabilities across base-image packages, embedded binaries, and application dependencies, and "use minimal base images" is one of Safeguard's own documented best practices for reducing container attack surface. That matters because a smaller image doesn't scan itself — Safeguard still needs to confirm the custom JRE and the application JAR sitting alongside it are both clean at build time and stay clean afterward. And when a CVE does land on something inside the image later — a JDK module, a native library, or a dependency JAR — Safeguard's self-healing containers can track the affected binary at the layer level and rebuild that specific component, including scratch- and distroless-style minimal images built exactly like the jlink output above, without requiring a full rebuild-from-source cycle initiated by a human.