Packaging and JVM Modes

This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against.

Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change.

This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Running a Quarkus application on the JVM is not a single, fixed artifact shape — ./mvnw package can produce several structurally different outputs depending on quarkus.package.jar.type, each trading off startup speed, distribution simplicity, and container-image size differently. This page covers those JVM package types and their quarkus.package.* configuration, then two build-time techniques that shrink the JVM’s own startup and memory cost further — AOT caching and dependency tree-shaking — plus JLink custom runtime images as an alternative to a full JRE base image. It closes with build reproducibility: CycloneDX SBOM generation, releasing the resulting artifacts, and a decision table comparing JVM fast-jar, a JLink runtime, and native image head to head. Native image’s own build process and the closed-world assumption both package types rely on are covered in Build-Time Augmentation and the Closed World; this page only compares native image’s operating characteristics against the JVM options, it does not re-explain how native-image itself works.

Package types

quarkus.package.jar.type selects which of five structurally different JAR layouts ./mvnw package produces. Only one can be active per build:

Type Artifact shape When to use it

fast-jar (default)

A quarkus-app/ directory tree, not a single JAR — see layout below.

The default for a reason: fastest JVM startup of the JAR types, and the shape every "Getting Started" guide and container-image extension assumes.

legacy-jar

A single <artifact>-runner.jar plus a sibling lib/ directory of dependency JARs, referenced via its manifest Class-Path.

Pre-1.12 Quarkus layout, kept for tooling or deployment pipelines that specifically expect it.

uber-jar

One self-contained <artifact>-runner.jar with every dependency’s classes unpacked and merged inside it.

Distributing a single file matters more than startup time or a minimal image layer — copying one artifact into a scratch container, or handing a jar to something that cannot unpack a directory tree.

mutable-jar

A quarkus-app/ tree that additionally retains augmentation’s intermediate state, enabling re-augmentation.

Iterative, production-like testing where a change should re-augment without a full clean build. Fully covered, alongside re-augmentation itself, in Build-Time Augmentation and the Closed World; not re-explained here.

aot-jar

A quarkus-app/ tree structured for JDK class-data/AOT-cache loading, produced by enabling quarkus.package.jar.aot.enabled.

Paired with AOT caching below rather than chosen on its own — see that section.

The fast-jar layout

Because fast-jar is the default, its directory shape is worth naming explicitly. ./mvnw package writes it to target/quarkus-app/:

Path Contents

quarkus-run.jar

The thin, executable entry-point JAR — java -jar target/quarkus-app/quarkus-run.jar starts the application. It contains no application code itself, only the bootstrap logic and a classpath manifest pointing at the directories below.

lib/

Third-party dependency JARs, split into lib/main (application dependencies) and lib/boot (the minimal set needed before augmentation-recorded bytecode takes over), pre-indexed so the JVM does not need to scan JAR contents at startup.

app/

The application’s own compiled classes and resources — what src/main/java and src/main/resources compiled to.

quarkus/

Generated, augmentation-produced classes: the bytecode that @Recorder calls were compiled into, plus the application’s generated-bytecode.jar and build-time-computed metadata.

Splitting the layout this way is what makes fast-jar fast to start: the JVM can memory-map an already-indexed, already-separated set of directories instead of opening and scanning a single large archive, and a container image can cache the rarely-changing lib/ layer separately from the frequently-changing app/ layer.

Configuration

The properties below are the ones most builds actually touch; quarkus.package.jar.* has several more narrowly-scoped options (manifest sections, optional-dependency filtering) covered in full in the Maven Tooling guide linked in References.

Property Default Purpose

quarkus.package.jar.type

fast-jar

Selects the package type: fast-jar, legacy-jar, uber-jar, mutable-jar, or aot-jar.

quarkus.package.output-directory

target (or the build tool’s equivalent)

Where the final artifact or quarkus-app/ tree is written.

quarkus.package.output-name

The Maven/Gradle artifact id

Base file name for the produced artifact, without suffix or extension.

quarkus.package.jar.compress

true

Whether entries inside the produced JAR(s) are compressed; disabling it trades a larger artifact for a faster build.

quarkus.package.jar.add-runner-suffix

true

Appends -runner to the legacy-jar/uber-jar file name; quarkus.package.jar.runner-suffix overrides the suffix itself.

quarkus.package.jar.decompiler.enabled

false

Decompiles the augmentation-generated bytecode in quarkus/ back to readable Java, written under quarkus.package.jar.decompiler.output-directory — a debugging aid for understanding what a @Recorder call actually produced, not something to enable for a shipped build.

A complete example

src/main/resources/application.properties:

# Base package type -- overridden per-build below where needed.
quarkus.package.jar.type=fast-jar

# Keep the artifact name stable regardless of the Maven version qualifier.
quarkus.package.output-name=inventory-service

# Record a manifest attribute useful for support tickets.
quarkus.package.jar.manifest.attributes."Build-Vendor"=Irurueta

Building the default fast-jar and overriding the type for a one-off uber-jar build both work from the same project, with no other configuration change:

# Default build -- produces target/quarkus-app/ (fast-jar).
./mvnw package

# One-off override -- produces target/inventory-service-runner.jar (uber-jar), leaving
# application.properties untouched.
./mvnw package -Dquarkus.package.jar.type=uber-jar

The first command leaves target/quarkus-app/quarkus-run.jar (plus its lib/, app/, quarkus/ siblings) as the runnable artifact, alongside the plain target/inventory-service-1.0.0-SNAPSHOT.jar that Maven’s own jar plugin always produces first and that fast-jar’s build step repackages from. The second command instead leaves a single target/inventory-service-runner.jar containing every dependency’s unpacked classes — nothing under target/quarkus-app/ is produced for that build.

Three further, independent techniques reduce what the JVM has to do at every process start, on top of the package-type choice above.

AOT caching

Starting with JDK 24, Project Leyden’s AOT cache lets a JVM process record the classes it loaded and the code the JIT compiled during a representative run, then load that recorded cache on every later start instead of repeating class loading, verification and (re-)JIT-warmup from scratch — narrowing, on the JVM, part of the startup-time gap that has historically favored native image. Quarkus wires this up through the aot-jar package type described above: enabling quarkus.package.jar.aot.enabled=true (via -D on the Maven build, or in application.properties) produces a quarkus-app/ tree structured so the JDK’s own AOT-cache tooling can consume it, and quarkus.package.jar.aot.type selects the cache format (auto, aot, app-cds, or scc, with app-cds as the pre-JDK-24 fallback using the older Application Class-Data Sharing mechanism). The cache itself is trained — built from an actual run of the application, ideally exercising its real request paths — which quarkus.package.jar.aot.phase lets you trigger either during the build or during the project’s own integration tests, so the shipped cache reflects genuine startup and warm-up behavior rather than an empty run. The resulting application still starts with java -jar quarkus-run.jar, but pointed at the cache with an added flag, e.g. -XX:AOTCache=app.aot. Because the cache is tied to the exact JDK build that produced it, it must be regenerated whenever the runtime JDK changes.

Tree-shaking

Fast-jar’s lib/ directory does not automatically contain only the classes the application actually reaches — by default it contains every class in every declared dependency’s JAR, whether augmentation’s closed-world analysis ever found a path to it or not. quarkus.package.jar.tree-shake.mode turns on tree-shaking: dropping classes from bundled dependency JARs that augmentation determined are unreachable from the application’s own closed-world graph, shrinking both the artifact and the amount the JVM has to index at startup. quarkus.package.jar.tree-shake.excluded-artifacts exempts specific dependency coordinates from shaking — for a library that loads a class reflectively in a way augmentation’s analysis cannot see, tree-shaking it away would silently break that reflective lookup at runtime, so exclude it explicitly rather than disabling tree-shaking project-wide.

A standard JVM deployment bundles the application against a full JRE — every module the JDK ships, whether the application uses it or not. The JDK’s own jlink tool builds a custom runtime image instead: a purpose-built mini-JRE containing only the JDK modules (plus, via Quarkus’s quarkus-jlink extension, the application’s own modules) the application actually needs, alongside a generated launcher script. The result is smaller to store and ship, faster to start because there is less runtime surface to initialize, and more self-contained than depending on whatever JRE image a target host or base container happens to provide. quarkus.jlink.enabled turns image generation on (Maven/Gradle build this image into quarkus.jlink.output-directory, target/jlink-output by default); quarkus.jlink.image-path and quarkus.jlink.launcher-name control where the image and its launcher land, and quarkus.jlink.min-heap-size / quarkus.jlink.max-heap-size bake default heap flags into that launcher. As of this writing the extension needs Java 25 or later and is marked experimental in the upstream guide — worth trying for a leaner JVM-mode container base image, but not yet something to depend on as a stable, unchanging build step.

Build reproducibility

CycloneDX SBOM generation

Adding io.quarkus:quarkus-cyclonedx as a project dependency makes every subsequent ./mvnw package emit a CycloneDX-format Software Bill of Materials for the packaged application automatically — no extra goal invocation is required, and no opt-in flag needs setting once the dependency is present. The distribution SBOM lands next to the built artifact as <executable-name>-cyclonedx.json (the format is configurable via quarkus.cyclonedx.format, e.g. xml); a separate quarkus:dependency-sbom Maven goal can additionally record the dependency graph before packaging, written as target/<artifactId>-<version>-dependency-cyclonedx.json. Either SBOM is what a vulnerability scanner or a supply-chain policy consumes — it lists every artifact, with its coordinates and (where known) license, that ended up in the shipped output.

Releasing the artifacts

Packaging and SBOM generation produce the artifacts; getting them from target/ to a GitHub Release, a container registry, and a changelog entry is a separate concern that Quarkus itself has no opinion on. JReleaser is a general-purpose Java release-automation tool that fits well here: pointed at the JARs (or container images) ./mvnw package already produced, it can sign them, publish to Maven Central or a registry, generate release notes, and cut a GitHub/GitLab release, all driven from one declarative configuration file kept alongside the project rather than a hand-rolled release script.

Choosing a JVM/native strategy

The package types and techniques above, plus native image (covered in Build-Time Augmentation and the Closed World), sit at genuinely different points on the same trade-off curve:

Strategy Startup time Memory (RSS) Build time Portability Tooling maturity

JVM fast-jar

Moderate — milliseconds to a few seconds

Moderate — full JRE resident

Fastest — no AOT compilation step

Highest — runs on any conforming JVM, any OS/arch with one installed

Most mature — the default path every guide and extension targets

JVM + JLink custom runtime

Similar to fast-jar, slightly faster class loading

Lower than a full JRE — unused modules dropped

Slightly slower than plain fast-jar — an extra jlink step

Lower — the image is tied to the OS/arch it was linked for, unlike a portable JAR

Experimental — requires Java 25+, upstream guide still marked experimental

Native image

Fastest — tens of milliseconds, no JIT warm-up

Lowest — no JVM, no JIT, ahead-of-time-compiled machine code only

Slowest — GraalVM reachability analysis and AOT compilation are heavyweight

Lowest — one executable per target OS/arch, closed-world restrictions apply to every dependency

Maturing fast, but still the option most likely to need per-library reflection configuration

No single row is a universal answer: fast-jar remains the safest default for anything still stabilizing its dependency set, native image is worth its build-time and dependency-compatibility cost for latency- or density-sensitive deployments (serverless, high pod-count Kubernetes), and a JLink runtime is a middle ground worth revisiting as it matures, for JVM deployments that specifically want a smaller, more self-contained image without native image’s closed-world constraints.