Debugging Spring Boot 4.x ClassNotFoundException: JarLauncher in Layered Docker Builds

The mechanics of layered container images has undergone a change in Spring Boot 4.x, with -Djarmode=layertools option being replaced by the streamlined -Djarmode=tools engine.

This causes older multi-stage container build recipes to fail at image runtime with:

Error: Could not find or load main class org.springframework.boot.loader.launch.JarLauncher
Caused by: java.lang.ClassNotFoundException: org.springframework.boot.loader.launch.JarLauncher

TL;DR

In Spring Boot 4.x, the java -Djarmode=tools command to extract layers only retrieves the application structure by default, leaving out the JarLauncher infrastructure unless explicitly instructed. When spring-boot-loader isn’t packed with the bootstrap launch classes, it leads to a ClassNotFoundException on container startup.

The --launcher flag must be added to instruct the extraction tool to include loader binaries inside the spring-boot-loader/ directory:

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

Without it, the spring-boot-loader/ directory generated during extraction only holds metadata and dependency structural maps, and misses the compiled bootstrap classes under /application/org.springframework.boot.loader.launch.*.

Because JarLauncher is executed as the primary class, (ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]) Java expects to find its compiled bytecode file precisely at /application/org/springframework/boot/loader/launch/JarLauncher.class.
Without the --launcher flag, that file simply does not exist.


The Symptom & The Setup

A multi-stage Dockerfile optimizes layer caching by extracting the fat JAR to functional directories before assembly:

# Stage 1: Extract fat jar layers
FROM eclipse-temurin:25-jre-noble AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
# Strip down the fat JAR to distinct directory layers
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted   # <-- problem

# Stage 2: Assembly
FROM eclipse-temurin:25-jre-noble
WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./

ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Building this image (podman build -t may-app-service .), and running it (podman run --rm -p 8080:8080 my-app-service) likely fails with ClassNotFoundException.


Auditing the Image Filesystem

Diagnosing the problem requires a peek within the generated image layer structure to determine if the classloader files actually exist in your execution context.

Option 1: Override the Entrypoint to Browse the Filesystem

Because the container crashes on startup, you need to override the default entrypoint with a standard shell (sh or bash) to keep the container alive for inspection.

podman run --rm -it --entrypoint sh my-app-service

Recursively inspect the working directory at the interactive prompt:

ls -R

* **A working build will have** the compiled bootstrap bytecode extracted by `spring-boot-loader` in the root:
  ```text
  ./org/springframework/boot/loader/launch:
  JarLauncher.class
  LaunchedURLClassLoader.class
  • A broken build will be missing an org/ directory under /application.
    Spring Boot’s application code is present inside BOOT-INF/, but the orchestrating engine required to launch it is absent.

Option 2: Export the filesystem to the Host

For stripped down minimal (distroless) images that lack an sh, one can copy workspace files to the local machine for verification:

podman create --name debug-container my-app-service
podman cp debug-container:/application ./local-debug-dir
podman rm debug-container

Open ./local-debug-dir to confirm whether JarLauncher.class is present in the context root. Alternatively, you can extract the image’s filesystem to a tarball and inspect it directly:

# Create a local tar file of the image filesystem
podman save -o image.tar my-app-service

# List the contents searching for the missing class
tar -tf image.tar | grep JarLauncher.class

Option 3: Use ‘dive’ for Deep Layer Auditing

Dive is a terminal UI utility that lets you visually inspect changes layer-by-layer, without starting containers or zip exports.
It identifies image efficiency, analyzes contents, and explicitly isolates what file transitions occur during individual COPY or RUN blocks.

podman run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive my-app-service

(Podman on Windows, requires the Podman named pipe/socket service to be running locally).

What to look for:

  1. In the left-hand panel navigate to the final COPY --from=builder ... steps corresponding to the spring-boot-loader layer.
  2. Inspect the right-hand directory tree view: Added files will appear in green.
  • A broken build, will have a near empty spring-boot-loader layer folder containing just non-executable structural indexes.

  • A working build, will display a structural tree right down to org/springframework/boot/loader/launch/JarLauncher.class.

You can get pre-compiled binaries or installers from Github repos, or use winget install wagoodman.dive on Windows, or, brew install dive on macOS.


The Root Cause

In Spring Boot 4.x, running java -Djarmode=tools -jar app.jar extract --layers only extracts your application dependencies and raw code structure, while stripping out the loader infrastructure by default.

Because the spring-boot-loader directory inside the intermediate build stage does not actually receive the physical org/springframework/boot/loader/launch/* class structures, the subsequent COPY instruction transfers nothing but metadata indexes to the final image.


The Resolution: Add the --launcher Flag

To fix this, you must explicitly instruct Spring Boot’s extraction utility to unpack the bootstrap launcher classes along with the rest of your application metadata layers.

Update your Stage 1 Extraction block to include the --launcher argument:

# Stage 1: extract fat jar layers
FROM eclipse-temurin:25-jre-noble AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY \${JAR_FILE} app.jar

# FIX: Explicitly request the launcher package components
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted

# Stage 2: Create layered, optimized production image
FROM eclipse-temurin:25-jre-noble
WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./

EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

The Result

With the --launcher flag applied, the build tool successfully populates the spring-boot-loader path with the foundational classes.

When Stage 2 copies the folders into the working directory, java org.springframework.boot.loader.launch.JarLauncher can natively map the classpath from your layers.idx file and cleanly start up your Spring Boot microservice.