Packaging Spring Boot Apps: Executable and Layered JARs

Introduction

This tutorial covers how a Spring Boot application is packaged and run outside the IDE. We will start by building the application into an executable jar and looking inside it - its folders, its manifest, and the launcher that makes java -jar work for a jar full of other jars. We will then run the jar and configure it from the command line. Next we will look at layered jars: the layers Spring Boot records in the jar, extracting them with the jarmode=tools launcher, and why they make container images smaller to update. Lastly, we will see two ways to build a container image - a Dockerfile that uses the layers, and Cloud Native Buildpacks - and how extraction helps with faster startup through Java's AOT cache.

(This tutorial packages the application from Your First Spring Boot Application with Spring InitializrClick to read tutorial: Your First Spring Boot Application with Spring Initializr.)

Building the executable jar

The spring-boot-maven-plugin that Spring Initializr adds to every project hooks into Maven's package phase. A normal build produces the jar -
./mvnw package
 OUTPUT - the jar files in target/ after the build
first-app-0.0.1-SNAPSHOT.jar
first-app-0.0.1-SNAPSHOT.jar.original
Two files appear. Maven's own jar plugin first builds an ordinary jar containing only the project's classes; Spring Boot's plugin then repackages it into an executable jar that also contains every dependency, and keeps the original, renamed to .jar.original. The executable jar is the one you ship. (The tests ran too in a normal build; they were skipped here with -DskipTests to keep the example short.)

Inside the executable jar

A selection of the jar's entries shows its layout (the full listing has hundreds of lines) -  OUTPUT - selected entries of jar tf first-app-0.0.1-SNAPSHOT.jar
META-INF/MANIFEST.MF
org/springframework/boot/loader/launch/JarLauncher.class
BOOT-INF/classes/application.properties
BOOT-INF/classes/com/javabrahman/firstapp/FirstAppApplication.class
BOOT-INF/classes/com/javabrahman/firstapp/HelloController$AppInfo.class
BOOT-INF/classes/com/javabrahman/firstapp/HelloController.class
BOOT-INF/lib/tomcat-embed-core-11.0.27.jar
BOOT-INF/lib/spring-boot-4.1.2.jar
BOOT-INF/lib/spring-webmvc-7.0.10.jar
BOOT-INF/classpath.idx
BOOT-INF/layers.idx

Explanation of the layout

  • BOOT-INF/classes/ holds the application's own classes and resources - the controller, the main class (and its nested record), and application.properties.
  • BOOT-INF/lib/ holds every dependency as a complete, unmodified jar - 34 of them for this application, including Spring Boot, Spring MVC and embedded Tomcat. That is why the file is about 20 MB and why it is called a fat jar or uber jar.
  • BOOT-INF/classpath.idx lists the library jars in classpath order, and BOOT-INF/layers.idx assigns every entry to a layer, as shown below.
  • org/springframework/boot/loader/ is the Spring Boot loader, copied in as plain classes at the root of the jar.
Plain Java cannot load classes from a jar that is inside another jar. The loader solves this, and the manifest tells Java to start it -  OUTPUT - META-INF/MANIFEST.MF of the executable jar
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.5.1
Java-Version: 25
Build-Jdk-Spec: 25
Implementation-Title: first-app
Implementation-Version: 0.0.1-SNAPSHOT
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: com.javabrahman.firstapp.FirstAppApplication
Spring-Boot-Version: 4.1.2
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
Main-Class is not our main class but JarLauncher. When you run java -jar, Java starts JarLauncher, which reads the Spring-Boot-* entries, creates a class loader that can read the nested jars in BOOT-INF/lib, and then calls the main() method of Start-Class - our FirstAppApplication. (This is the LaunchedClassLoader that appeared in the log of the DevTools tutorialClick to read tutorial: Spring Boot DevTools: Automatic Restart and Productivity.)

Running the jar

The jar runs anywhere a Java 25 runtime is installed. Command-line arguments become properties, as explained in application.properties vs application.ymlClick to read tutorial: application.properties vs application.yml in Spring Boot, so the same jar can be configured at launch - here with a different port and application name -
java -jar target/first-app-0.0.1-SNAPSHOT.jar --server.port=9090 --spring.application.name=shop-prod
 OUTPUT of requests to the running jar on port 9090
> curl http://localhost:9090/hello?name=Harry
Hello, Harry!

> curl http://localhost:9090/info
{"application":"first-app","builtWith":"Spring Boot","date":"2026-11-06"}
 OUTPUT - the Tomcat lines of the jar's console log
2026-11-06T13:43:17.140+05:30  INFO 19552 --- [shop-prod] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat initialized with port 9090 (http)
2026-11-06T13:43:19.650+05:30  INFO 19552 --- [shop-prod] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 9090 (http) with context path '/'
The application answered on port 9090, and its log lines carry the name shop-prod - both settings came from the command line, with no change to the jar. JVM options go before -jar, as in java -Xmx512m -jar app.jar; application arguments go after the jar name. To run the application as a service, a process manager such as systemd on Linux, or a container platform, starts this same command.

Layered jars

Container images are built in layers, and when an image is rebuilt, only the layers that changed have to be rebuilt, pushed and pulled. A fat jar copied into an image is one 20 MB layer, so even a one-line code change ships all 20 MB again - although the 34 library jars almost never change. Spring Boot therefore writes BOOT-INF/layers.idx into every jar, splitting its contents into four layers ordered from least to most likely to change -  OUTPUT - BOOT-INF/layers.idx
- "dependencies":
  - "BOOT-INF/lib/"
- "spring-boot-loader":
  - "org/"
- "snapshot-dependencies":
- "application":
  - "BOOT-INF/classes/"
  - "BOOT-INF/classpath.idx"
  - "BOOT-INF/layers.idx"
  - "META-INF/"
  • dependencies: the released library jars - large, and they change only when you upgrade a library.
  • spring-boot-loader: the launcher classes, which change only with Spring Boot upgrades.
  • snapshot-dependencies: libraries with -SNAPSHOT versions, which may change at any build (empty here).
  • application: our classes and resources - small, and they change with every commit.

Extracting the layers with jarmode=tools

To use the layers, they have to be taken out of the jar. Every Spring Boot jar contains a second mode of operation for this: started with the system property -Djarmode=tools, it runs Spring Boot's jar tools instead of the application -  OUTPUT of java -Djarmode=tools -jar first-app-0.0.1-SNAPSHOT.jar help
Usage:
  java -Djarmode=tools -jar first-app-0.0.1-SNAPSHOT.jar

Available commands:
  extract      Extract the contents from the jar
  list-layers  List layers from the jar that can be extracted
  help         Help about any command
 OUTPUT of the list-layers command
dependencies
spring-boot-loader
snapshot-dependencies
application
The extract command with --layers writes one folder per layer, and --launcher keeps the Spring Boot launcher so that the extracted application starts the same way as the jar -  OUTPUT - the layer folders, a sample from the dependencies layer, and the application layer's classes
application
dependencies
snapshot-dependencies
spring-boot-loader
spring-boot-4.1.2.jar
FirstAppApplication.class
HelloController$AppInfo.class
HelloController.class
Each layer is now an ordinary folder; the application layer holds just our three compiled classes, in BOOT-INF/classes/com/javabrahman/firstapp. A Dockerfile copies the folders into the image one by one, least-changing first, so that each becomes its own image layer -
Dockerfile - a two-stage build that copies the layers separately
FROM eclipse-temurin:25-jre AS builder
WORKDIR /builder
COPY target/first-app-0.0.1-SNAPSHOT.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --launcher --destination extracted

FROM eclipse-temurin:25-jre
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"]
After a code change, only the last COPY produces a new layer - a few kilobytes instead of 20 MB. (Docker was not available on the machine that ran this tutorial's examples, so the Dockerfile is shown but was not built here.)

Container images with Cloud Native Buildpacks

Writing and maintaining a Dockerfile is optional. The Spring Boot Maven plugin can build an OCI container image directly, with Cloud Native Buildpacks (the Paketo buildpacks by default) -
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=javabrahman/first-app:1.0
The buildpacks choose a JRE, apply the layer split automatically, set a memory configuration suited to containers, and produce a ready-to-run image, without a Dockerfile. The command needs a running Docker (or compatible) daemon, which is why it is shown but not run in this tutorial. In Gradle, the task is bootBuildImage.

Extracting for faster startup

extract without --layers produces a plain application jar next to a lib/ folder of dependencies, which starts with an ordinary java -jar and no nested-jar launcher. That form is what the JVM's startup optimizations need: on Java 25, a training run records an AOT cache of loaded and linked classes, and later runs start faster from it -
java -Djarmode=tools -jar target/first-app-0.0.1-SNAPSHOT.jar extract --destination app
java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar app/first-app-0.0.1-SNAPSHOT.jar
java -XX:AOTCache=app.aot -jar app/first-app-0.0.1-SNAPSHOT.jar
The property spring.context.exit=onRefresh makes the training run stop as soon as the application context has started. How the AOT cache works is explained in the tutorial on Java 24 and 25 AOT cache for faster startupClick to read: Java 24 and 25 AOT cache for faster startup, and startup tuning comes back in module 11 of this series, together with GraalVM native images.

Other packaging options

  • WAR files: choosing War packaging on Spring Initializr produces a WAR that can be deployed to an external servlet container such as a standalone Tomcat, and still runs with java -jar. Use it only when the deployment platform requires it.
  • Gradle: ./gradlew bootJar builds the same executable, layered jar.
  • Native images: GraalVM can compile the application ahead of time into a native executable that starts in milliseconds, with ./mvnw -Pnative native:compile; covered in module 11.

Summary

In this tutorial we first built the application with ./mvnw package and saw the Spring Boot plugin repackage it into an executable fat jar. We looked inside the jar - the application in BOOT-INF/classes, 34 dependency jars in BOOT-INF/lib, the index files and the Spring Boot loader - and read the manifest that makes JarLauncher start our main class. We then ran the jar on another port with command-line properties. Next we read the four layers in layers.idx, used the jarmode=tools launcher to list and extract them, and wrote a Dockerfile that copies each layer separately. Lastly, we saw how Cloud Native Buildpacks build an image without a Dockerfile, how a plain extraction supports Java's AOT cache, and other packaging options. This completes the Spring Boot essentials module; the series continues with building REST APIs with Spring MVC.