Take this multi-stage Dockerfile:

FROM eclipse-temurin:21-jdk-ubi9-minimal AS builder

ARG APP_VERSION
WORKDIR /application/

COPY build/libs/app-${APP_VERSION}.jar app.jar

RUN java -Djarmode=tools -jar app.jar extract --layers --launcher

################
FROM eclipse-temurin:21.0.6_7-jre-ubi9-minimal

WORKDIR /opt/app/

ENV TZ=Europe/Budapest

COPY --from=builder /application/app/dependencies/          ./
COPY --from=builder /application/app/snapshot-dependencies/ ./
COPY --from=builder /application/app/spring-boot-loader/    ./
COPY --from=builder /application/app/application            ./

ENTRYPOINT exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher

With this Dockerfile, you can build reproducible container images. It's layered, which means that if you change only the application code, the layers containing the dependencies remain untouched. This saves both space in the image registry and network bandwidth.

The builder stage uses JDK 21 as the base image and copies the fat (or uber) JAR from the host machine's filesystem, where it was previously built. We use the extract command in this stage to explode the JAR into separate folders. These folders are then copied into the second stage, with one COPY command per folder—ensuring each set of files goes into its own layer.

The second stage uses only the JRE as a base, since we just need the runtime environment to launch the application.

To run the application, I defined the ENTRYPOINT instruction. I used the exec form to ensure the Java process runs as PID 1 inside the container. This is important because, if you wrap the command in a shell script, the script would be PID 1 instead, and when the container is stopped, the shell might not properly forward termination signals to your application—causing the app to shut down abruptly. That can lead to data loss and other issues. See explanation on StackOverflow

My spellchecker complained that I wasn’t using the JSON array syntax for ENTRYPOINT, like this:

ENTRYPOINT ["exec", "java", "${JAVA_OPTS}", "org.springframework.boot.loader.launch.JarLauncher"]

However, if I used this form, it would fail. In the JSON array form, the ${JAVA_OPTS} variable is not resolved—it is passed literally. The result would be:

exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher

Which is not what we want.

Further reading: