Packaging

A plain build stops at jars. Packaging turns those jars into something a user can run without a Java project in front of them: a self-contained application image, a trimmed runtime, a zip to drop onto a JRE, a single executable jar, a container image, or an ahead-of-time-compiled native binary. Each form answers the same question differently - how much of the runtime travels with the program - and this chapter walks through the options.

Every form is opt-in and driven by convention, not a build script. You name it in a packaging.properties file in the configuration folder, and it runs for every module that declares a main class.

Key in packaging.properties Produces
jpackage=app-image | deb | rpm | dmg | pkg | exe | msi a self-contained application image or a native installer, one per type for several types separated by commas
jlink=true a custom runtime image (modular only)
jmod=true a .jmod module file (modular only)
bundle=true a bundle.zip to drop onto a stock JRE
launcher=true a single executable jar
docker=<base image> a container build context - a Dockerfile and the jars it copies, labelled by docker.label.<name>=<value> lines, or a jpackage package with docker.jpackage=<type>
native=true a GraalVM native binary
As with every configuration file, the first folder that carries one wins. A module's own build.jenesis/ packages that module alone, while a project-wide file packages them all. The jmod step joins the module's own build; every other key adds a step to the package phase, which runs after every module has built, so packaging never blocks a sibling's compile.

What makes a module packageable

Only one thing: a declared entry point. It is the same @jenesis.main tag - or <mainClass> property - that Execute already launches a module with:

/**
 * @jenesis.main sample.Sample
 */
module demo.modular.executable {
    exports sample;
}

Every packaging step keys off that one declaration and skips a module that has none. A library needs no packaging configuration to be left alone, and an application needs no packaging-specific entry point.

The application image

jpackage=app-image produces a self-contained application image: a native launcher with its own bundled Java runtime, so a user runs it without installing a JDK. It is the only jpackage type that needs no platform-native tooling, which makes it the CI-friendly choice.

# build.jenesis/packaging.properties
jpackage=app-image
java build/jenesis/Make.java stage

The --name, --main-jar/--main-class (or --module) arguments are derived from the module's coordinate and main class. The stage target collects each produced image under stage/packages/, the staging analogue of stage/maven and stage/modular, in a folder named for the module it came from:

target/stage/packages/output/demo.modular.executable/   the image jpackage produced
|-- bin/demo.modular.executable   # the launcher
`-- lib/   # app jars + bundled runtime

The image bundles the whole runtime closure, not just your own code: a dependency your app uses sits next to the application jar. Because the image is self-contained, a deployable container needs no JDK, only a minimal base:

FROM debian:stable-slim
COPY target/stage/packages/output/demo.modular.executable /opt/app
ENTRYPOINT ["/opt/app/bin/demo.modular.executable"]

docker.jpackage=app-image writes this file for you, as A container build context below shows.

jpackage links the bundled runtime from the very JDK that compiled the code and ran the tests. The app ships on exactly the JVM it was built and verified against, not whatever patch version a base image happens to provide.

Modular images are smaller

How big the image is depends on the layout. A modular project lets jpackage run jlink internally and trim the bundled runtime to just the modules the graph resolves. A class-path (Maven-layout) project cannot be trimmed, so it ships a full runtime.

Measured with Temurin 25.0.3, a modular app-image is about 57 MB against about 138 MB for the class-path sibling. The gap is almost entirely the JVM: java.base alone links to ~60 MB and a full JDK is ~303 MB.

Passing jpackage its own flags

jpackage has flags of its own - an icon, a vendor, a description, a licence file. They go in a process-jpackage.properties file in the configuration folder, one flag per line, exactly as Building & running described for javac:

# build.jenesis/process-jpackage.properties
--vendor=Example Ltd
--icon=branding/app.png

process-jlink.properties, process-jmod.properties and process-native-image.properties do the same for the tools below. Some flags are derived for you, and a flag in the file takes precedence over a derived one:

  • --app-version comes from jenesis.project.version with any non-numeric suffix stripped, because jpackage accepts only dotted numbers - 1.4.0-SNAPSHOT becomes 1.4.0.
  • --description is the module's description, on one line, --vendor is the name of its organization, and --copyright is the copyright that project.properties declares, so jpackage does not write the year of the build into a package's copyright instead.
  • For an installer, --about-url is the project's URL, and --license-file is the module's own licence file, the one the build lays at the root of its legal notices (see Licences in each form).
  • For a deb, --linux-deb-maintainer is the e-mail of the first developer the POM names, which jpackage pairs with the vendor as the package's maintainer; for an rpm, --linux-rpm-license-type names the project's licences by their SPDX ids, joined by OR, and only when every licence has one, as identified from spdx.properties and the built-in tables (see Supply-chain features).

Each is passed only where the project declares the value and the package type takes the flag: an application image takes no licence file and no URL.

Native installers

The other jpackage values build a native installer - the single artifact you hand a user to install, rather than a directory to launch in place. The value is passed straight to jpackage --type:

Value Platform
deb, rpm Linux
exe, msi Windows
dmg, pkg macOS

An installer carries the whole bundled runtime, so it is tens of megabytes. Producing one needs the platform's own packaging tooling on the PATH: dpkg-deb/fakeroot for deb and rpmbuild for rpm on Linux, the WiX Toolset on Windows, the bundled productbuild/hdiutil on macOS. For that reason an installer is usually built locally, while the tooling-free app-image covers the packaging path in CI.

Several types in one build

jpackage takes several types, separated by commas, and builds one package per type. Each type is built once, and every package is staged side by side under stage/packages/:

# build.jenesis/packaging.properties
jpackage=app-image,deb
target/stage/packages/output/
|-- demo.modular.executable/                the application image
`-- demo.modular.executable_1.0_amd64.deb   the installer

Each type is handed only the flags it takes, so a derived installer flag such as --about-url never reaches the application image. What process-jpackage.properties sets reaches every type, and a process-jpackage-<type>.properties beside it adds flags for that type alone:

# build.jenesis/process-jpackage-deb.properties
--linux-deb-maintainer=ops@example.com

Runtime images and .jmod files

Two modular-only keys expose the lower-level artifacts that jpackage builds internally. Both need modules, so a class-path project has nothing to link or pack.

jlink=true links a custom runtime image holding only the modules your app needs, staged under stage/runtime. It runs straight from its own bin/java with no JDK installed:

target/stage/runtime/output/module-sources/bin/java \
    -m demo.modular.executable/sample.Sample Ada Lovelace

The folder under output/ is the module's build identity rather than its name: module for a module whose descriptor sits at the project root, module-<folder> otherwise - module-sources for a module under sources/. The docker context below uses the same naming.

jmod=true packs the module into a .jmod, staged beside the modular jar. It holds everything the jar holds - the classes, the resources and the embedded SBOM - so a runtime linked from it serves the same resources. Its one advantage over a jar is that it can carry native libraries, commands, config files and legal notices, which jlink then lays into the runtime's lib/, bin/, conf/ and legal/. The three steps chain - jmod → jlink → jpackage - so a config file packed this way reaches the shipped app, where the program reads it from <java.home>/conf/. Packed into a jar instead, it would be stranded there.

jlink links explicit modules only. Every jar it links must carry a module-info, or be a .jmod. A plain jar - and an automatic module, which declares no requires of its own - is rejected with "automatic module cannot be used with jlink". With jlink=true beside it, jpackage is handed that linked runtime and inherits the rule. On its own, jpackage stages the jars as its module path and roots the whole path instead, as the bundle section below describes. The next section is how a closure of plain jars becomes one jlink accepts.

Making a closure linkable

A dependency that ships as a plain jar - or that you gave a name with a module alias - is an automatic module, and jlink will not take one. A modules.properties file in the configuration folder closes that gap by turning the module's whole resolved closure into explicit named modules:

# build.jenesis/modules.properties
mode=declared

An empty file means the same thing, since declared is the default. Every jar that already declares a module-info passes through untouched. For the rest, jdeps works out what each one actually reads and a generated module-info is injected into a copy of it.

The rewritten closure then replaces the resolved one for everything the module builds: javac, the tests, and every packaging step. A module graph that does not hold together - a split package, two jars claiming one name, a requires nothing provides - fails at compile or test time instead of first appearing in the shipped image.

The mode key decides what happens to a jar with no name of its own to carry: declared fails the build and names the coordinate, synthetic invents a stable name derived from the jar's digest, and none skips the rewrite - which is how a single module opts out of a project-wide file.

Nothing that describes what you fetched is affected. The bill of materials, the licence and vulnerability checks, and the closure pin records all keep reading the artifacts as they were downloaded, so a rewritten jar's bytes can never reach a @jenesis.pin checksum.

Bundles for a JRE base

jpackage bundles a runtime into every image. The lighter alternative is to ship only your jars onto an off-the-shelf JRE base. bundle=true wires a step that writes one bundle.zip per runnable module:

bundle.zip
|-- application.unix.args   # the launch, as a Java argument file
|-- application.windows.args   # the same launch, with Windows path separators
`-- jars/   # every jar of the closure, stored once

The zip carries exactly the runtime closure the Execute launcher would run, and the descriptor is not a description of that launch but the launch itself - a Java argument file, which every JVM already understands:

"--module-path"
"jars/demo.bundle-0-SNAPSHOT.jar:jars/org.slf4j-2.0.16.jar"
"--module"
"demo.bundle/sample.Sample"

So a deployment runs it without a reader or a parser of its own:

cd <unpacked> && java @application.unix.args

Every path is spelled out rather than handed over as a folder, so a jar is read because the argument file names it, not because of where it sits - and since the whole command lives in a file, no closure is too large to launch. The module layers a project declares are in there too, each as a -Djlayer.modulepath.<layer>. There are two files because the path separator is the one part of a launch a bundle cannot know in advance. Dropped onto a -jre base it needs no JDK and no jpackage.

The trade against an app-image is the classic one. An app-image is self-contained but duplicates the JVM per service. A bundle is tiny and shares one JVM layer across every image built on the same base - leaner in aggregate for many services, at the cost of coupling to that base's JVM version.

Why --add-modules sometimes appears. A module graph is self-contained when every jar on the module path is an explicit named module, so the launcher reaches all of them through the main module's requires. An automatic module, a plain jar, or a module layer holding either breaks that, because a module used only internally is never pulled in. The build detects this and writes --add-modules ALL-MODULE-PATH,ALL-DEFAULT into the launch, rooting the whole module path and the default platform modules. jpackage, the container context and the native image apply the same correction; you never splice it in yourself.

A container build context

Writing that Dockerfile around a bundle by hand is the one step the build can do for you. The docker key takes the base image - the one thing no build can infer - and stages a complete build context:

# build.jenesis/packaging.properties
docker=eclipse-temurin:25-jre
java build/jenesis/Make.java stage
docker build -t sample target/stage/docker/output/module-sources

The staged folder holds a generated Dockerfile beside the jars/ folder it copies in and the argument file its ENTRYPOINT names:

FROM eclipse-temurin:25-jre
LABEL "org.opencontainers.image.base.name"="eclipse-temurin:25-jre" \
      "org.opencontainers.image.title"="sample" \
      "org.opencontainers.image.version"="1.0.0" \
      ...
WORKDIR /app
COPY jars/ /app/jars/
COPY application.args /app/
ENTRYPOINT ["java", "@/app/application.args"]

The entry point is the same one every other packaging form reads, so a container can never drift from what the app image or the launcher jar starts, and it stays this size however many jars the application resolves. When the module graph is not self-contained, the argument file carries the same --add-modules=ALL-MODULE-PATH,ALL-DEFAULT correction.

The base image and the labels are the only knobs, and deliberately so: ENV, USER, EXPOSE and the rest are inherited from the base, so image environment belongs in a base image rather than in build configuration.

Labels

The LABEL instruction carries the standard org.opencontainers.image.* keys, filled from what the project declares - its name, description, version and URL, source repository and revision, organisation, developers and licences, as its SBOM names them. Licences are written as an SPDX expression when every one is identified. Every standard key is written, empty where the project declares nothing, so no label of the base image carries over. created holds jenesis.archive.timestamp when that is set explicitly - to the time of the commit that is built with -Djenesis.archive.timestamp=$(git log -1 --format=%cI), for example (see Building & running) - and is empty otherwise.

A docker.label.<name>=<value> line adds a label of your own or replaces a standard one; an empty value writes it empty:

# build.jenesis/packaging.properties
docker=eclipse-temurin:25-jre
docker.label.org.opencontainers.image.documentation=https://jenesis.build

Extending the image

The launch ends both paths with a folder the build leaves out, so an image built FROM this one can add jars without replacing the entry point: /app/extensions/modulepath on the module path and /app/extensions/classpath/* on the class path. java skips a folder that does not exist, so an image that adds nothing runs as before.

FROM sample
COPY my-extension.jar /app/extensions/modulepath/

A module there is resolved when it provides a service the application uses, and a jar on the class path is found through its META-INF/services entries. The application's own jars come first on both paths, so an extension adds to the application but never replaces one of its modules. Options for java - a system property, a module to resolve by name - go into JDK_JAVA_OPTIONS, which an image extends rather than replaces:

ENV JDK_JAVA_OPTIONS="${JDK_JAVA_OPTIONS} --add-modules com.example.extension"

A jpackage package in the image

docker.jpackage=<type> beside docker=<image> puts a jpackage package into the image instead of the jars and the argument file. The package carries its own runtime, so the base image needs no Java at all:

# build.jenesis/packaging.properties
docker=debian:stable-slim
docker.jpackage=app-image

The staged context then holds the application image beside the Dockerfile, which starts its launcher:

FROM debian:stable-slim
LABEL ...
WORKDIR /app
COPY ["demo.modular.executable/", "/app/"]
ENTRYPOINT ["/app/bin/demo.modular.executable"]

The type is one a Linux image can run, and any other is refused:

Type In the image
app-image copied to /app and started by its own launcher
deb installed with apt-get, which also installs the system libraries the package declares
rpm installed with dnf, yum or zypper, whichever the base image has, or else with rpm

An installed deb or rpm is started through /app/launcher, a link to the launcher the package installed. The labels are written as before. The /app/extensions/ folders have no counterpart here, because jpackage fixes the application and its runtime when it links them.

The type is built for the image whether or not jpackage lists it, and staged under stage/packages/ only when it does. A type both name is built once:

packaging.properties Staged under stage/packages/ In the image
docker.jpackage=app-image nothing an application image
jpackage=deb and docker.jpackage=app-image the deb an application image
jpackage=app-image,deb and docker.jpackage=deb both the staged deb
jpackage builds packages for the platform it runs on, so an image of a jpackage package needs a build on Linux. On macOS or Windows the build stops and names -Djenesis.project.docker=true, which runs the build in a Linux container instead.
The build never invokes a container tool - it writes files - so producing the context needs no Docker installation at all. Nothing in the generated file is Docker-specific: podman build and buildah bud consume the same folder.

A single executable jar

launcher=true produces a single executable jar you run with java -jar app.jar, without flattening dependencies into a fat jar. The build shades the published Jenesis Launcher into the jar as its Main-Class and explodes each dependency into its own jars/<jar>/ subfolder, with an application.properties naming which of them each path holds. At run time the launcher rebuilds the module graph from those subfolders in process, so module-infos and META-INF/services never collide.

Unlike jpackage and bundle, this carries no JVM and no jlink runtime. It is a plain jar that runs on any JDK 25 or newer, and unlike a bundle it needs no launch script. The shaded launcher is pinned like any other dependency, in its own launcher group, so the exact bytes are verified and the build stays reproducible.

The launcher jar has its own section - see Jenesis Launcher for how it reconstructs the module layer, the jar layout, and troubleshooting.

A modular jar on the class path

A modular jar declares its services in module-info.java. The module path reads them from there, but a ServiceLoader on the class path finds a provider only through a META-INF/services/<service> file. To have one jar serve both, place an empty classpath.properties in a configuration folder:

build.jenesis/
`-- classpath.properties

For each provides <service> with <providers> clause in a module's compiled module-info, the jar then gets a META-INF/services/<service> file that names the providers, one per line. A module without a module-info is a class-path jar already and gets nothing.

Two other differences are handled as well:

  • A module that grants native access to itself with @jenesis.native also gets Enable-Native-Access: ALL-UNNAMED in its manifest, which java -jar reads where the module path reads --enable-native-access. A manifest that already sets the attribute keeps its value.
  • The main class needs nothing: @jenesis.main puts it into the manifest and into module-info alike.

A module that ships its own META-INF/services/<service> for a service it also provides fails the build, because the build writes that file itself.

-Djenesis.generate.classpath=false switches the step off, in a profile if need be, without deleting the file.

Native images

native=true compiles the application ahead of time into a single standalone native executable with GraalVM native-image: a binary that starts in milliseconds and carries no Java runtime, because the runtime it needs is linked into the binary itself. The stage target collects it under stage/native, and you run it directly, with no java in the command:

target/stage/native/output/demo.graal.image Ada

Native compilation needs GraalVM. The tool is located through GRAALVM_HOME, then the running JDK's own bin/, then PATH, so either run the build on a GraalVM JDK or point GRAALVM_HOME at one:

GRAALVM_HOME=~/.sdkman/candidates/java/25.0.3-graal java build/jenesis/Make.java stage

Reachability metadata, captured from tests

native-image's closed-world analysis cannot see reflection, JNI, resources, or proxies, so it needs reachability metadata for anything dynamic. Jenesis captures that automatically: drop a graal.properties marker file in the configuration folder and its presence attaches GraalVM's tracing agent to the test run. The agent records every dynamic access the tests trigger, and the native build picks it up directly. A single build both captures the metadata and compiles the image, with no committed META-INF/native-image/ directory to maintain.

The capture is only as complete as your tests. If a reflective path is never exercised, its metadata is never recorded and the binary fails at run time with ClassNotFoundException. You can still commit metadata by hand under sources/META-INF/native-image/, which native-image discovers inside every jar - the way to vet exactly what reflection is baked into a published artifact.

native-image or jpackage?

Both turn a modular app into something a user runs without a JDK, but they differ in kind. jpackage ships your bytecode plus a trimmed JVM: normal startup, tens of megabytes, no extra tooling. native-image compiles the program and its runtime into machine code: near-instant startup and a small binary, at the cost of GraalVM, a slow compile, and complete reachability metadata. They are alternatives, not a progression. Choose jpackage for a faithful bundle of the JVM you tested against, and native-image when startup and footprint matter more.

Licences in each form

A shipped program carries the code of its dependencies, and with it their licences and notices. How each form passes them on follows from how it holds the jars:

Form Where the licences travel
Module jar It holds your own code only. Its licence text is placed with jenesis.project.resources (see Supply-chain features), and the embedded SBOM names the licence of every dependency.
Bundle Every jar of the closure is stored intact under jars/, so each dependency's licence files travel inside its own jar.
Launcher jar Each jar is unpacked under a prefix of its own, jars/<jar>/, and nothing is merged, so every jar keeps its files. The launcher's own META-INF/LICENSE and META-INF/NOTICE sit at the root beside its classes.
Container build context The jars are copied intact into jars/ beside the Dockerfile.
Class-path application image The jars stay intact in the image's application folder (lib/app/ on Linux), and the runtime jpackage links carries the JDK's own notices in its legal/ folder.
Runtime image, modular application image Linking takes the code out of the jars, so the notices are collected into the module's .jmod and laid into the runtime under legal/<module>/: the module's own at its root, and each runtime dependency's in a folder named after its jar.
Native image The jars are compiled away, so the same notices go into a licenses/ folder beside the binary, staged in stage/native/output/. The binary also contains the GraalVM that compiled it, so that GraalVM's licence and notice files join them in licenses/graalvm-<version>/.

Linking always goes through the module's .jmod, so a runtime keeps the notices however it is configured: without jmod=true, jlink and a modular jpackage build the .jmod for linking alone and do not stage it. The runtime of the java-modular-executable demo, which redistributes org.slf4j, carries that library's licence:

target/stage/runtime/output/module-sources/legal/demo.modular.executable/
`-- org.slf4j-2.0.16/LICENSE.txt

jenesis.legal.notices names the jar entries taken as notices, comma-separated. The default is META-INF/NOTICE,META-INF/LICENSE,META-INF/license/,META-INF/licenses/,LICENSE,about.html. A name matches regardless of case and also with an extension, so META-INF/LICENSE.txt counts, and an entry ending in / takes the whole folder below it.

A dependency whose jar carries no licence file cannot contribute one, and many jars carry none: such a jar adds nothing to legal/, so check its licence before shipping it.