Docker Multi-Stage Builds for Java: Minimal JRE Images
A Java image built the obvious way carries a full JDK, Maven and your source code into production, none of which the application needs to run. This article shows how to ship only what it does need: a multi-stage build, a custom JRE made with jlink, layers that make rebuilds fast, a non-root user, and two alternatives that need no Dockerfile tuning at all.
Contents
The starting point: a single-stage Dockerfile
This is how most Java Dockerfiles start. It copies the whole project into a JDK image, builds it there, and runs the result:
FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests
EXPOSE 8080
CMD ["java", "-jar", "target/app.jar"]
It works, but everything used to build the application stays in the image: the full JDK (compiler and tools included), the Maven repository with every build and test dependency, and your source code. None of it is needed to run the application. It makes the image several times larger than it needs to be, slower to push and pull, and gives an attacker who gets into the container more tools to use.
Multi-stage build
A multi-stage build uses one image to build the application and a second, smaller one to run it. Only the files you copy across with COPY --from
# Stage 1: build the JAR
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
# Download dependencies first. This layer is reused until pom.xml changes.
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline -q
COPY src src
RUN ./mvnw package -DskipTests -q
# Stage 2: run it on a JRE, without Maven or the source code
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
The order of the COPYpom.xmlsrc
21-jre-alpineLayer caching: rebuild only what changed
Docker builds an image as a stack of layers, one per instruction. When you build again, it reuses every layer whose inputs did not change. When you push or pull, it skips layers the other side already has.
A Spring Boot JAR works against this. Your own code and all of your libraries sit inside one file, so changing a single line changes the whole JAR. Docker then rebuilds, pushes and pulls every library again, even though none of them changed.
The fix is to split the JAR into four folders, grouped by how often they change, and copy each one in its own step, the least changing first:
| Folder | What it holds | Changes when |
|---|---|---|
dependencies | Released libraries | You add or upgrade a library |
spring-boot-loader | The code that starts the application | You upgrade Spring Boot |
snapshot-dependencies | SNAPSHOT libraries | Often, while they are in development |
application | Your classes and resources | Every build |
Spring Boot records these folders in the JAR by default, so no change to pom.xml
# Build the JAR and split it into its four folders
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline -q
COPY src src
RUN ./mvnw package -DskipTests -q
RUN java -Djarmode=tools -jar target/*.jar extract --layers --launcher --destination extracted
# Copy the folders, least changing first
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Now, when only your code changed, Docker reuses the first three folders from its cache and rebuilds only application
docker history <image>-Djarmode=toolsjava -Djarmode=layertools -jar app.jar extractorg.springframework.boot.loader.JarLauncherA custom JRE with jlink
A standard JRE contains every Java module, including ones most applications never use, such as the desktop UI libraries. jlinkjdeps
jdeps
# Stage 1: build the JAR
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline -q
COPY src src
RUN ./mvnw package -DskipTests -q
# Stage 2: work out the modules and build a JRE with only those
FROM eclipse-temurin:21-jdk AS jre-builder
WORKDIR /work
COPY --from=builder /app/target/*.jar app.jar
RUN mkdir unpacked && cd unpacked && jar xf ../app.jar
RUN jdeps --ignore-missing-deps --print-module-deps --recursive \
--multi-release 21 \
--class-path 'unpacked/BOOT-INF/lib/*' \
unpacked/BOOT-INF/classes > modules.txt
RUN jlink --add-modules "$(cat modules.txt),jdk.crypto.ec" \
--strip-debug --no-man-pages --no-header-files \
--compress=zip-6 \
--output /custom-jre
# Stage 3: the custom JRE and the application on a plain OS image
FROM debian:12-slim
ENV JAVA_HOME=/opt/java
ENV PATH="${JAVA_HOME}/bin:${PATH}"
COPY --from=jre-builder /custom-jre ${JAVA_HOME}
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
jdepsjdk.crypto.ecjava.namingjdk.managementjdk.zipfsThe JRE must run on the same kind of C library it was built with. One built in the standard Temurin JDK image runs on glibc-based images such as debian:12-slimubuntueclipse-temurin:21-jdk-alpinealpine
Running as a non-root user
By default, a container runs as root
FROM eclipse-temurin:21-jre
WORKDIR /app
# A system user with no password and no login shell
RUN groupadd --system app && useradd --system --gid app --no-create-home app
COPY --from=builder /app/target/*.jar app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
The user only needs to read the JAR, so there is no need to change its owner. Ports below 1024 need root, which is one more reason to keep the default port 8080.
-XX:MaxRAMPercentage=75
docker run --read-only --tmpfs /tmp myapp/tmpDistroless base images
Google's distroless images contain the Java runtime and the few system files it needs, and nothing else: no shell, no package manager, no utilities. An attacker who gets in has almost nothing to work with. The nonroot
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline -q
COPY src src
RUN ./mvnw package -DskipTests -q
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
# The image's entry point is "java -jar", so only the JAR is given here
CMD ["app.jar"]
The trade-off is debugging: with no shell, docker exec -it ... sh:debug-nonroot
No Dockerfile: Spring Boot Buildpacks
Spring Boot can build an image without a Dockerfile, using Cloud Native Buildpacks. It applies most of this article by itself: a JRE-only image, the layers from the layer caching section, a non-root user, and memory settings worked out from the container's limit when it starts.
# Maven (Docker must be running)
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myorg/payment-service
# Gradle
./gradlew bootBuildImage --imageName=myorg/payment-service
To set the image name and Java version in the build itself, configure the Maven plugin:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>myorg/${project.artifactId}:${project.version}</name>
<env>
<BP_JVM_VERSION>21</BP_JVM_VERSION>
</env>
</image>
</configuration>
</plugin>
Buildpacks are a good default when you have no special requirements. Write a Dockerfile when you need full control over the base image or the steps.
Keeping the build context small
docker build.dockerignore
# Build output: the image builds its own
target/
build/
# Version control and IDE files
.git/
.idea/
*.iml
.vscode/
.DS_Store
# Local files that must never reach an image
.env
*.log
Do not exclude .mvn/mvnw
Which approach to choose
| Approach | What it removes | Good for |
|---|---|---|
| Multi-stage build | The JDK, Maven and source code | Every Java image: the baseline |
| Layered JAR | Nothing; makes rebuilds and pushes faster | Applications you deploy often |
| Distroless | The shell and OS tools | Production, when you rarely need a shell |
| jlink custom JRE | Java modules the application does not use | The smallest image, if you test it well |
| Buildpacks | Most of the above, without a Dockerfile | Teams that want good defaults with no upkeep |
Sizes depend on your application and libraries, so measure your own with docker images