How to Create Smaller Docker Image with JLink and JDEPS

Today we're going on a container diet journey that'll make your Spring Boot apps leaner than the bloated things you often see during your Docker builds.
TL;DR
Alright, alright! I see you scrolling down looking for the goods. Here's the complete Dockerfile that'll make your containers skinnier than a supermodel on a juice cleanse:
- JDK 21
- JDK 25
- Additional Info
#
# Multi-stage Dockerfile for Java Spring Boot application
#
# Stage 1: Build stage - Where the magic happens
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /usr/src/project
# Copy Maven files first because we're smart like that
# (Docker cache is your best friend, treat it right)
COPY pom.xml mvnw ./
COPY .mvn/ .mvn/
RUN chmod +x mvnw
# Download dependencies - this is usually the longest stage
# Trust me, without caching this step, you'll age visibly
RUN ./mvnw dependency:go-offline
# Now for the actual source code (the star of the show!)
COPY src/ src/
# Build the thing! Skip tests because YOLO production deployment
# (Just kidding, run your tests in CI/CD like a responsible adult)
RUN ./mvnw clean package -DskipTests
# Time for some JAR surgery
RUN jar xf target/app.jar
# Let jdeps be the detective and figure out what we actually need
RUN jdeps \
--ignore-missing-deps \
-q --recursive \
--multi-release 21 \
--print-module-deps \
--class-path 'BOOT-INF/lib/*' \
target/app.jar > deps.info
# Create the minimalist JRE
# jdk.crypto.ec is for HTTPS, otherwise ignore it
RUN jlink \
--add-modules $(cat deps.info),jdk.crypto.ec \
--strip-debug \
--compress 2 \
--no-header-files \
--no-man-pages \
--output /jre-minimalist
# Stage 2: The lean, mean, production machine
FROM alpine:3.21.3 AS final
# Set up our custom Java home
ENV JAVA_HOME=/opt/java/jre-minimalist
ENV PATH=$JAVA_HOME/bin:$PATH
# Move in our custom-built JRE
COPY /jre-minimalist $JAVA_HOME
# Security 101: Don't run as root
RUN addgroup -S springgroup \
&& adduser -S springuser -G springgroup \
&& mkdir -p /app \
&& chown -R springuser:springgroup /app
# Bring over our precious application
COPY /usr/src/project/target/app.jar /app/
WORKDIR /app
USER springuser
#
# Launch sequence initiated! 🚀
#
# These JVM flags are like performance vitamins for your container:
# - MaxRAMPercentage: Don't be greedy, use 75% max
# - InitialRAMPercentage: Start with 50% like a reasonable person
# - MaxMetaspaceSize: Uncontrollable growing is cancer, in form of a deadly OutOfMemoryError
# - UseG1GC: Because G1 is the cool garbage collector, fine tune it for your actual usage
#
# Add other parameters to tailor to your project's needs
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:InitialRAMPercentage=50.0", "-XX:MaxMetaspaceSize=512m", "-XX:+UseG1GC", "-jar", "app.jar"]
#
# Multi-stage Dockerfile for Java Spring Boot application
#
# Stage 1: Build stage - Compile the application and create a minimal JRE
FROM amazoncorretto:25-alpine-full AS build
WORKDIR /usr/src/project
ENV JAVA_VERSION=25
# Copy Maven configuration files first to leverage Docker cache
COPY pom.xml mvnw ./
COPY .mvn/ .mvn/
RUN chmod +x mvnw
# Download dependencies (will be cached if pom.xml doesn't change)
RUN ./mvnw dependency:go-offline
# Copy source code
COPY src/ src/
# Build the application using Maven wrapper
RUN ./mvnw clean package -DskipTests
# Extract the JAR to analyze its dependencies
RUN jar xf target/app.jar
# Use jdeps to identify necessary Java modules for a minimal JRE
RUN jdeps \
--ignore-missing-deps \
-q --recursive \
--multi-release ${JAVA_VERSION} \
--print-module-deps \
--class-path 'BOOT-INF/lib/*' \
target/app.jar > deps.info
# Create a custom JRE with only the required modules
# jdk.crypto.ec is needed for HTTPS
# JDEPS currently does not detect this module
RUN jlink \
--add-modules $(cat deps.info),jdk.crypto.ec \
--strip-java-debug-attributes \
--compress 2 \
--no-header-files \
--no-man-pages \
--output /jre-minimalist
# Stage 2: Production stage - Minimal Alpine image with custom JRE
FROM alpine:3.22 AS final
# Set up Java environment
ENV JAVA_HOME=/opt/java/jre-minimalist
ENV PATH=$JAVA_HOME/bin:$PATH
# Copy the custom JRE from the build stage
COPY /jre-minimalist $JAVA_HOME
# Create a non-root user for security
RUN addgroup -S springgroup \
&& adduser -S springuser -G springgroup \
&& mkdir -p /app \
&& chown -R springuser:springgroup /app
# Copy application artifacts from build stage
COPY /usr/src/project/target/app.jar /app/
WORKDIR /app
USER springuser
#
# Run the application with optimized JVM settings
#
# - UseCompactObjectHeaders: See JEP 519 (https://openjdk.org/jeps/519)
# - MaxRAMPercentage: Limit max heap to 75% of container memory
# - InitialRAMPercentage: Start with 50% of container memory
# - MaxMetaspaceSize: Limit metaspace to 512MB
# - UseG1GC: Use the G1 garbage collector for better performance
ENTRYPOINT ["java", "-XX:+UseCompactObjectHeaders", "-XX:MaxRAMPercentage=75.0", "-XX:InitialRAMPercentage=50.0", "-XX:MaxMetaspaceSize=512m", "-XX:+UseG1GC", "-jar", "app.jar"]
In JDK 21, --strip-debug parameter for jlink works, but not anymore in JDK 25, and we have to use --strip-java-debug-attributes instead.
Also, in the pom.xml file, add the following line inside the <build> section:
<finalName>app</finalName>
This ensures the generated JAR file is always named app.jar, instead of something like your-awesome-project-0.0.1-SNAPSHOT.jar. This is especially useful if you don’t need versioned filenames for your application.
Still, a heads-up:
Sorry folks, this party is JDK 9+ only! If you're still on JDK 8, maybe it's time to have that awkward conversation with your tech lead about upgrading to JDK 17. I know, I know. They'll probably give you the classic "no resources, no time, no budget" triple threat. But hey, at least you tried!
Still with me? Cool! Let's break this bad boy down and see what's happening under the hood.
The THICC Problem with Default Java Images
Look, we need to talk about your Docker images. They're... how do I put this gently?
Absolutely THICC. Like, "takes-up-half-your-hard-drive" thicc. But don't worry!

Extra THICC indeed, Mr. Aku.
You may be using eclipse-temurin JDK and wondering why your "simple" Spring Boot app is pushing 300+ MB? Well, congratulations! You've just packed the entire Java kitchen sink into your container. Your app probably uses like 10% of what's in there, but Java's all "here, take ALL the modules, you might need them someday!"
(spoiler: most of the time, you might not)
Custom JRE with jlink
Java 9 gave us some pretty neat tools called jdeps (the nosy neighbor who knows exactly what your app is using) and jlink (the minimalist guru who helps you throw away everything you don't need).
Multi-Stage Dockerfile Breakdown
Stage 1: Build and Analysis
- JDK 21
- JDK 25
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /usr/src/project
COPY pom.xml mvnw ./
COPY .mvn/ .mvn/
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline
COPY src/ src/
RUN ./mvnw clean package -DskipTests
FROM amazoncorretto:25-alpine-full AS build
WORKDIR /usr/src/project
ENV JAVA_VERSION=25
COPY pom.xml mvnw ./
COPY .mvn/ .mvn/
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline
COPY src/ src/
RUN ./mvnw clean package -DskipTests
We're copying the Maven files first because Docker layer caching is basically magic. If your pom.xml doesn't change, Docker will "remember" this and use the cache to speed up the build process. Thank the almighty Docker Gods!
If your project does not have Maven wrapper (.mvnw folder and mvnw file), you can visit here to get your own
Still, consult with your DevOps engineers and ask them if caching is viable (and also about the option to purge the cache in case of caching problems).
During the build stage, you can spam RUN commands like you're texting your crush. Since these layers won't make it to the final image, go wild! Your future debugging self will appreciate the granular steps. It helps with caching too!
Playing with jdeps
Before we reach the next step, a small modification for the pom.xml file is needed: adding the following line to the <build> section:
<finalName>app.jar</finalName>
And now, the next part:
- JDK 21
- JDK 25
# The app is the name we defined inside <finalName> tag
RUN jar xf target/app.jar
RUN jdeps \
--ignore-missing-deps \
-q --recursive \
--multi-release 21 \
--print-module-deps \
--class-path 'BOOT-INF/lib/*' \
target/app.jar > deps.info
# Debug: Let's see what this sneaky tool found
RUN echo "Required Java modules:" && cat deps.info
# The app is the name we defined inside <finalName> tag
RUN jar xf target/app.jar
RUN jdeps \
--ignore-missing-deps \
-q --recursive \
--multi-release ${JAVA_VERSION} \
--print-module-deps \
--class-path 'BOOT-INF/lib/*' \
target/app.jar > deps.info
# Debug: Let's see what this sneaky tool found
RUN echo "Required Java modules:" && cat deps.info
jdeps is basically telling you what modules you are actually using since 2019. The --ignore-missing-deps flag is crucial because sometimes dependencies are like "hey, I reference this class" but it's nowhere to be found.
I added that console log so you can see what modules jdeps thinks you need. You could even copy-paste this list and hardcode it for slightly faster builds.
Is the performance gain worth it? Probably not. Will it make you feel like an optimization hack? Absolutely!
jlink, the Minimalist Guru
- JDK 21
- JDK 25
RUN jlink \
--add-modules $(cat deps.info),jdk.crypto.ec \
--strip-debug \
--compress 2 \
--no-header-files \
--no-man-pages \
--output /jre-minimalist
RUN jlink \
--add-modules $(cat deps.info),jdk.crypto.ec \
--strip-java-debug-attributes \
--compress 2 \
--no-header-files \
--no-man-pages \
--output /jre-minimalist
jlink is where the magic happens! It's like having a personal trainer for your JRE: no fluff, no unnecessary bulk, just pure, lean runtime goodness. We're manually adding jdk.crypto.ec because jdeps sometimes doesn't realize that HTTPS exists, and in the sample project, we need HTTPS support.
Stage 2: The Lean Production Machine
FROM alpine:3.22 AS final
ENV JAVA_HOME=/opt/java/jre-minimalist
ENV PATH=$JAVA_HOME/bin:$PATH
COPY /jre-minimalist $JAVA_HOME
The Great libc Compatibility Drama
Here's where things get spicy. You CANNOT mix and match your build and runtime environments like you're at a salad bar! Both stages need to be Alpine-compatible, or you'll get the dreaded "No such file or directory" error that will haunt your dreams.
The villain of this story? libc compatibility:
- glibc: The popular kid (Ubuntu, CentOS, RHEL, Debian)
- musl libc: The cool alternative kid (Alpine Linux)
These two are like oil and water: they just don't mix! If you build on glibc and run on musl (or vice versa), your container will throw a tantrum like a toddler denied candy.
This is why we use eclipse-temurin:21-jdk-alpine or amazoncorretto:25-alpine-full for building (anything that supports Linux Alpine). We're keeping everything in the Alpine family. It's like a very exclusive club, but for libraries. Again, alpine is what makes our reduced image size successful!
Security Theater
RUN addgroup -S springgroup \
&& adduser -S springuser -G springgroup \
&& mkdir -p /app \
&& chown -R springuser:springgroup /app
COPY /usr/src/project/target/app.jar /app/
WORKDIR /app
USER springuser
Running as root is like leaving your front door open with a sign that says "Free Stuff, Please Take".
Don't be that person. Create a nice, boring user account that can't accidentally nuke your entire system.
JVM Tuning
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:InitialRAMPercentage=50.0", "-XX:MaxMetaspaceSize=512m", "-XX:+UseG1GC", "-jar", "app.jar"]
These JVM flags are like seasoning for your container, and the right amount makes everything better:
MaxRAMPercentage=75.0: "Hey Java, don't be a memory hog"InitialRAMPercentage=50.0: "Start reasonable, scale as needed"MaxMetaspaceSize=512m: "Metaspace, you have a budget. Stick to it!"UseG1GC: "Use the fancy garbage collector that makes things go zoom"
For JDK 25 only, you can add +UseCompactObjectHeaders to use the new compact object headers (JEP 519).
The Results
This approach typically shrinks your images by 60-80%! That's like going from an SUV to a smart car – same functionality, way less bulk, and your wallet will thank you when those storage bills come in.
As you can see, the image size is around 150 MB, compared to what might have been 400-500 MB initially with "naive" full JDK inclusion method.

Wanna Try This Magic for Yourself?
I've got you covered! Check out the complete working example at my sample repository. Just run the run-docker.cmd file and watch the automation magic happen!
That script is Windows-only because I'm a Windows person (don't @ me). Need it for Linux or Mac? Just copy-paste it into Claude AI and ask Sonnet to translate it. It's like Google Translate, but for shell scripts!
The Bottom Line
Creating custom JREs with jlink is like learning to cook instead of ordering takeout every night – it takes a bit more effort upfront, but the long-term benefits are totally worth it. Your images will be smaller, your deployments faster, and your security posture stronger.
Just remember the golden rules:
-
Keep your build and runtime environments compatible (Team Alpine all the way!)
-
Test everything thoroughly (because nobody likes surprises in production)
-
Don't trust
jdepscompletely – sometimes you need to manually add modules
Now go forth and create some beautifully slim Docker images! Your infrastructure team will probably send you a thank-you card. Or at least stop giving you dirty looks during deployment time.
You can also go back here and copy/paste as you like, after making such a tedious trip to the end of this article.