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.

Share

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:

Dockerfile
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 end up in the final image:

Dockerfile
# 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 COPY lines in the first stage matters. Docker reuses a step from its cache when the files it copied have not changed. Copying pom.xml and downloading dependencies before copying src means a code change does not download all the dependencies again.

The examples use the standard Temurin images, which are based on Ubuntu. Alpine variants (21-jre-alpine) are smaller, but Alpine uses a different C library (musl), which some native libraries do not support. Test your application on Alpine before switching.

Layer 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:

FolderWhat it holdsChanges when
dependenciesReleased librariesYou add or upgrade a library
spring-boot-loaderThe code that starts the applicationYou upgrade Spring Boot
snapshot-dependenciesSNAPSHOT librariesOften, while they are in development
applicationYour classes and resourcesEvery build

Spring Boot records these folders in the JAR by default, so no change to pom.xml is needed. The first stage builds the JAR and extracts the folders; the second copies them in order:

Dockerfile
# 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. A push uploads only that layer, and servers that already have the previous version download only that layer.

To see it, change one line of code, build again and run docker history <image>: only the top layers have a new creation time. The -Djarmode=tools command needs Spring Boot 3.3 or later. On older versions, use java -Djarmode=layertools -jar app.jar extract, and org.springframework.boot.loader.JarLauncher before 3.2.

A custom JRE with jlink

A standard JRE contains every Java module, including ones most applications never use, such as the desktop UI libraries. jlink builds a JRE with only the modules you list, and jdeps works out which modules your code and its libraries use.

jdeps cannot look inside a Spring Boot JAR directly, because the libraries are nested in it. So the JAR is unpacked first:

Dockerfile
# 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"]
jdeps only finds modules your code refers to directly. Modules loaded at run time are missed, so the application can fail only when it reaches them. The most common one is jdk.crypto.ec, which HTTPS connections need, so it is added above. Others you may need are java.naming (JNDI, some database drivers), jdk.management (metrics) and jdk.zipfs. Run the image through your full test suite before relying on it.

The 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-slim or ubuntu, but not on Alpine. To run on Alpine, use eclipse-temurin:21-jdk-alpine in the first two stages and alpine in the last.

Running as a non-root user

By default, a container runs as root. If an attacker finds a way into the application, they have root inside the container, which makes escaping it easier. Create a user and switch to it before the application starts:

Dockerfile
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 sets the heap to 75% of the container's memory limit. Without it, Java uses only 25% of the limit for the heap, which wastes most of the memory you give the container. The rest is left for the JVM's own memory: thread stacks, class metadata and buffers.

To go further, run the container with a read-only file system: docker run --read-only --tmpfs /tmp myapp. The application can then write only to /tmp, which Spring Boot needs for uploads and the embedded server's working files.

Distroless 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 variant also runs as a non-root user already.

Dockerfile
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 does not work. The :debug-nonroot tag of the same image adds a small shell for when you need one.

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.

Shell
# 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:

XML
<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 first sends the whole project folder, the build context, to the Docker engine. A .dockerignore file in the project root leaves out what the build does not need, which makes builds faster and stops files such as local credentials from reaching an image by accident:

Text
# 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/ or mvnw: the build stage uses them.

Which approach to choose

ApproachWhat it removesGood for
Multi-stage buildThe JDK, Maven and source codeEvery Java image: the baseline
Layered JARNothing; makes rebuilds and pushes fasterApplications you deploy often
DistrolessThe shell and OS toolsProduction, when you rarely need a shell
jlink custom JREJava modules the application does not useThe smallest image, if you test it well
BuildpacksMost of the above, without a DockerfileTeams that want good defaults with no upkeep

Sizes depend on your application and libraries, so measure your own with docker images rather than relying on published numbers. As a rule, a multi-stage build is the biggest single saving, and the other techniques each take off a smaller amount.