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. file in the configuration folder, and it runs for every module
that declares a main class.
Key in packaging. |
Produces |
|---|---|
jpackage= | 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= |
a custom runtime image (modular only) |
jmod= |
a . module file (modular only) |
bundle= |
a bundle. to drop onto a stock JRE |
launcher= |
a single executable jar |
docker= |
a container build context - a Dockerfile and the jars it copies, labelled by docker. lines, or a jpackage package with docker. |
native= |
a GraalVM native binary |
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. 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= 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/, the staging analogue
of stage/ and stage/, 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. writes this file for you, as A container build context
below shows.
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.
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. 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., process-jmod. and process-native-image. 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-versioncomes fromjenesis.with any non-numeric suffix stripped, because jpackage accepts only dotted numbers -project. version 1.becomes4. 0-SNAPSHOT 1..4. 0 --descriptionis the module's description, on one line,--vendoris the name of its organization, and--copyrightis thecopyrightthatproject.declares, so jpackage does not write the year of the build into a package's copyright instead.properties - For an installer,
--about-urlis the project's URL, and--license-fileis 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-maintaineris the e-mail of the first developer the POM names, which jpackage pairs with the vendor as the package's maintainer; for anrpm,--linux-rpm-license-typenames the project's licences by their SPDX ids, joined byOR, and only when every licence has one, as identified fromspdx.and the built-in tables (see Supply-chain features).properties
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/:
# 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. sets reaches every type, and a
process-jpackage-<type>. 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= links a custom runtime image holding only the modules your app needs, staged under
stage/. It runs straight from its own bin/ 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= packs the module into a ., 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.. 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. 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.
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= wires a step that writes one bundle. 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.. 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.
--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= 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. 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. when that is set explicitly - to the time of
the commit that is built with -Djenesis., for example (see
Building & running) - and is empty otherwise.
A docker. 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: / on the module path and
/ 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/ 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_, 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. beside docker= 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 / 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 /, a link to the launcher the package installed.
The labels are written as before. The / 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/ only
when it does. A type both name is built once:
packaging. |
Staged under stage/ |
In the image |
|---|---|---|
docker. |
nothing | an application image |
jpackage= and docker. |
the deb |
an application image |
jpackage= and docker. |
both | the staged deb |
-Djenesis.project.docker=true, which
runs the build in a Linux container instead.
podman build and
buildah bud consume the same folder.
A single executable jar
launcher= produces a single executable jar you run with java -jar app., 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/ subfolder, with an application. 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/ 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.
A modular jar on the class path
A modular jar declares its services in module-info.. The module path reads them from there, but a
ServiceLoader on the class path finds a provider only through a META-INF/ file. To have
one jar serve both, place an empty classpath. 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/ 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.also getsnative Enable-Native-Access: ALL-UNNAMEDin its manifest, whichjava -jarreads where the module path reads--enable-native-access. A manifest that already sets the attribute keeps its value. - The main class needs nothing:
@jenesis.puts it into the manifest and intomain module-infoalike.
A module that ships its own META-INF/ for a service it also provides fails the build,
because the build writes that file itself.
-Djenesis. switches the step off, in a profile if need be, without deleting the file.
Native images
native= 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/, 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_, then the running JDK's own
bin/, then PATH, so either run the build on a GraalVM JDK or point GRAALVM_ 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.
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/
directory to maintain.
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. (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/, and nothing is merged, so every jar keeps its files. The launcher's own META-INF/ and META-INF/ 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/ 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 . and laid into the runtime under legal/: 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/. The binary also contains the GraalVM that compiled it, so that GraalVM's licence and notice files join them in licenses/. |
Linking always goes through the module's ., so a runtime keeps the notices however it is configured:
without jmod=, jlink and a modular jpackage build the . for linking alone and do not stage it.
The runtime of the java-modular-executable demo, which redistributes org., carries that library's
licence:
target/stage/runtime/output/module-sources/legal/demo.modular.executable/
`-- org.slf4j-2.0.16/LICENSE.txt
jenesis. names the jar entries taken as notices, comma-separated. The default is
META-INF/. A name matches
regardless of case and also with an extension, so META-INF/ counts, and an entry ending in /
takes the whole folder below it.
legal/, so check its licence before shipping it.