Shipping a Java desktop application or a command-line tool used to mean asking users to install the right JDK first, then giving them a jar and a shell script. jpackage, standard in the JDK since 16 (JEP 392, after an incubator release in JDK 14), removes both steps. It links a trimmed Java runtime, adds a native launcher, and produces a package the operating system already knows how to install: an MSI or EXE on Windows, a DEB or RPM on Linux, a PKG or DMG on macOS.
This page walks through the tool from first principles: what it produces, why you need a build machine for every target operating system, how the app image is laid out and configured, how to control the runtime it bundles, and how to handle upgrades and code signing, which jpackage only partly does for you. Module selection is covered in depth in Java jlink, in depth, and server images in Java in containers. This page is about installers for machines you do not control.
What jpackage builds, in two stages
jpackage always works in two stages, and keeping them separate in your head explains most of its options. In the first stage it builds an app image: a directory that contains a native launcher executable, your jars or modules, a configuration file for the launcher, and a private Java runtime produced by jlink. The app image is runnable as it is. You can zip it and ship it, and users never need a JDK installed.
In the second stage it wraps the app image in a platform installer, using the operating system's own packaging tools. --type app-image stops after the first stage, and any other type runs both. You can also run stage two on its own with --app-image, pointing it at an image you built earlier. That matters because anything you do to the image between the stages, such as signing the launcher, survives into the installer.
Package types and the build matrix
The man page is blunt about it: each format must be built on the platform it runs on, and there is no cross-platform support. The launcher is a native executable, the runtime contains native libraries for one OS and CPU architecture, and the installer formats are produced by tools that exist only on their own platforms. Three operating systems, plus x64 and ARM variants, means up to six build machines.
| Build OS | --type values | Extra tooling required | Default install location |
|---|---|---|---|
| Windows | app-image, exe, msi | WiX Toolset (v3, or v4 and later on recent JDKs) | Program Files, or per-user with --win-per-user-install |
| Linux | app-image, deb, rpm | dpkg-deb or rpmbuild, depending on type | /opt |
| macOS | app-image, pkg, dmg | Xcode command-line tools | /Applications |
In CI this becomes a matrix job, one runner per target, each with a JDK of the same version and architecture as the runtime you want to ship. Check that your Windows runner image actually has WiX installed. If it does not, install it as a build step.
name: package
on: { push: { tags: ['v*'] } }
jobs:
package:
strategy:
matrix:
include:
- { os: windows-latest, type: msi }
- { os: macos-latest, type: dmg }
- { os: ubuntu-latest, type: deb }
runs-on: ${{ matrix.os }}
defaults: { run: { shell: bash } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21' }
- run: ./gradlew jar
- run: >
jpackage --type ${{ matrix.type }} --name Ledgerly --app-version 1.4.0
--vendor "Example Ltd" --input build/libs --main-jar ledgerly-1.4.0.jar
--dest dist
- uses: actions/upload-artifact@v4
with: { name: 'ledgerly-${{ matrix.type }}', path: dist/* }
Inputs: classpath or modules
jpackage accepts the two shapes a Java application can have. A classpath application is a directory of jars given with --input, plus --main-jar and, if the jar's manifest has no Main-Class, --main-class. Every file in the input directory is copied into the image, so point it at a clean directory containing only what you ship, not at a build output folder full of test jars. A modular application gives --module-path and --module module/mainclass. In that case your own modules are linked into the runtime image itself.
# Classpath application: everything in build/libs, main class in the jar's manifest
jpackage --type app-image --name Ledgerly --app-version 1.4.0 \
--input build/libs --main-jar ledgerly-1.4.0.jar \
--java-options '-Xmx512m' \
--java-options '-Dledgerly.home=$APPDIR' \
--dest out
# Modular application: launch module/class from a module path
jpackage --type app-image --name Ledgerly --app-version 1.4.0 \
--module-path build/jmods:build/libs \
--module com.example.ledgerly/com.example.ledgerly.Main \
--dest outTwo options control what the launcher passes to the JVM. --java-options adds JVM flags and --arguments adds default program arguments, and each can be repeated. Both accept the macros $APPDIR, $BINDIR and $ROOTDIR, which the launcher expands at run time to the installed application's directories. Use them for anything that refers to a file inside the installation, because you do not know the install path when you build. Use single quotes in a Unix shell so the shell does not expand $APPDIR first. A backslash escapes a literal dollar sign.
The app image and its .cfg file
The layout differs by platform. On Linux the image is Ledgerly/bin/Ledgerly for the launcher, Ledgerly/lib/app/ for your jars and the launcher configuration, and Ledgerly/lib/runtime/ for the Java runtime. On Windows it is Ledgerly/Ledgerly.exe with app/ and runtime/ beside it. On macOS it is a normal bundle, Ledgerly.app/Contents/MacOS/Ledgerly with Contents/app and Contents/runtime.
The launcher is a small, generic native program. It reads a text file named after it in the app directory to find the classpath, the main class and the JVM options. Read the file your build produces rather than guessing. It is the quickest way to debug a launcher that starts the wrong class or ignores a flag.
# out/Ledgerly/lib/app/Ledgerly.cfg (Linux layout; keys vary a little between JDK releases)
[Application]
app.classpath=$APPDIR/ledgerly-1.4.0.jar
app.mainclass=com.example.ledgerly.Main
[JavaOptions]
java-options=-Djpackage.app-version=1.4.0
java-options=-Xmx512m
java-options=-Dledgerly.home=$APPDIRUsers and support staff can edit this file after installation, for example to raise -Xmx. That is convenient, but the next upgrade overwrites it. If users need persistent settings, read them from a file in the user's home or application-data directory, not from the installation directory.
Controlling the bundled runtime
When you give no runtime, jpackage runs jlink for you. For a classpath application it includes a broad default set of modules, and it applies the jlink options --strip-native-commands --strip-debug --no-man-pages --no-header-files. That is safe but large. You have three ways to tighten it: --add-modules to name the modules, --jlink-options to replace the default flags, or --runtime-image to hand over a runtime you linked yourself. The last option is the most reproducible, because the same runtime directory can be tested on its own before it is packaged.
# 1. Ask jdeps which JDK modules the application needs
jdeps --print-module-deps --ignore-missing-deps --multi-release 21 \
--class-path 'build/libs/*' build/libs/ledgerly-1.4.0.jar
# -> java.base,java.desktop,java.net.http,java.sql
# 2. Build the runtime yourself, adding modules jdeps cannot see (locales; jdk.crypto.ec on JDK 21 and earlier)
jlink --add-modules java.base,java.desktop,java.net.http,java.sql,jdk.localedata \
--strip-debug --no-man-pages --no-header-files --output build/runtime
# 3. Hand it to jpackage instead of letting it link one
jpackage --type app-image --name Ledgerly --runtime-image build/runtime \
--input build/libs --main-jar ledgerly-1.4.0.jar --dest outjdeps cannot see modules that are reached only by reflection or service loading. Extra locales, JNDI providers and, on JDK 21 and earlier, elliptic-curve TLS (jdk.crypto.ec, whose code moved into java.base in JDK 22) are the usual surprises. They fail at run time, on a user's machine, often only on one code path. Run the application's real smoke test against the linked image, not against your development JDK.
Upgrades, versions and uninstall
An installer is only half the job. The other half is the next installer. On Windows, an MSI replaces an older version in place only if both share the same upgrade code. jpackage sets it from --win-upgrade-uuid. Generate one UUID per product, commit it to the build, and never change it. If you forget it, users end up with two entries in Add or Remove Programs. Windows Installer also limits the version format: up to three numeric fields, with the first two limited to 255 and the third to 65535. A version scheme such as 2026.10.2 fails that check, while 26.10.2 passes.
On Linux, the DEB and RPM version comes from --app-version and --linux-app-release, and the package manager handles upgrades by package name. Set --linux-package-name explicitly so it never changes. Declare runtime dependencies that the bundled JVM needs from the system, such as desktop libraries for a Swing application, with --linux-package-deps.
Uninstallers remove what the installer laid down and nothing else. Logs, caches and settings written under the user's home survive by design. Write them to the platform's per-user locations, and document how to remove them completely.
Signing and notarization
Unsigned installers trigger warnings on Windows and are blocked by default on macOS, so for any public distribution, signing is part of packaging. jpackage helps on macOS and does not help on Windows.
On Windows, sign the launcher executable inside the app image before it is wrapped, then sign the MSI or EXE itself. That requires the two-stage build described earlier.
REM Stage 1: app image only
jpackage --type app-image --name Ledgerly --app-version 1.4.0 ^
--input build\libs --main-jar ledgerly-1.4.0.jar --dest out
REM Sign the launcher (and any native libraries) inside the image
signtool sign /fd sha256 /tr <your-CA-timestamp-URL> /td sha256 /a out\Ledgerly\Ledgerly.exe
REM Stage 2: wrap the signed image in an MSI that upgrades older versions in place
jpackage --type msi --app-image out\Ledgerly --name Ledgerly --app-version 1.4.0 ^
--win-upgrade-uuid 3f6c2a1e-8b5d-4c7a-9e21-6d0b5f4a7c90 ^
--win-menu --win-shortcut --dest dist
signtool sign /fd sha256 /tr <your-CA-timestamp-URL> /td sha256 /a dist\Ledgerly-1.4.0.msiOn macOS, --mac-sign makes jpackage sign the bundle and its contents with a Developer ID identity from your keychain, selected with --mac-signing-key-user-name and --mac-signing-keychain. Pass --mac-entitlements if the hardened runtime needs extra permissions. Apple's notarization is a separate step that jpackage does not perform. Submit the signed DMG or PKG with xcrun notarytool submit --wait, then staple the ticket with xcrun stapler staple. Store the signing material in your CI secret store, and only on the macOS runner.
Extra launchers, file associations and services
One installation can carry several entry points. --add-launcher name=props adds a second launcher, described by a properties file with its own main class, arguments and Java options. Use it, for example, to ship a GUI and a command-line tool from the same runtime. On Windows, --win-console gives a launcher a console window, which a command-line tool needs. --file-associations registers document types with the operating system. --launcher-as-service asks the installer to register the main launcher as a background service. Check what it does on each platform before relying on it, and keep your own service definition if you need specific restart or user settings.
When the generated installer is close but not quite right, --resource-dir lets you override the templates jpackage uses, such as icons, WiX fragments, Debian control files and macOS Info.plist. Run once with --verbose and --temp to see which resources it uses and to keep the intermediate files. Override only the files you need, and expect to revisit them when you upgrade the JDK.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Windows build fails looking for WiX | WiX missing or not on PATH on the build machine | install WiX in the CI step; pin its version |
| Two copies installed after an upgrade | no or changed --win-upgrade-uuid | fix one UUID per product forever |
| ClassNotFoundException or NoClassDefFoundError for a JDK class on users' machines | module missing from a custom runtime | add it to jlink; smoke-test the linked image |
| TLS handshake fails only in the packaged app | JDK 21 or earlier: jdk.crypto.ec not linked | add the provider module explicitly |
| macOS says the app is damaged or from an unidentified developer | unsigned, or signed but not notarized and stapled | --mac-sign, then notarytool and stapler |
| Config change lost after upgrade | user edited the .cfg inside the installation | read user settings from the user's profile |
| Linux package will not install on another distro | package format or dependency names differ | build deb and rpm separately; declare dependencies |
Trade-offs
jpackage gives users a normal installation experience with a JIT-compiled JVM, full reflection and every library working as it does in development. The costs are size, because even a trimmed runtime adds tens of megabytes; the build matrix; and the fact that every JDK security update means rebuilding and redistributing every installer, because the runtime is private to your application. jpackage has no update mechanism of its own, so you must build or buy one. GraalVM native images give smaller, faster-starting binaries, but they need reflection configuration and lose the JIT's peak performance. A plain jlink image in a zip suits technical users and avoids installer work. For server software, a container image is almost always the better artifact.
What to do next
- Build an app image on your own OS with
--type app-image, run it, and read the generated .cfg file. - Run jdeps, build your own runtime with jlink, and smoke-test the real application against it.
- Generate a permanent
--win-upgrade-uuidand fix your Linux package name and version scheme before the first public release. - Set up a CI matrix with one runner per OS and architecture you support, and upload each installer as an artifact.
- Add signing: two-stage signtool on Windows;
--mac-signplus notarytool and stapler on macOS. - Plan how users get the next version, including JDK security updates, before you ship the first one.