2.5 JLink And JPackage
Once your application is complete and tested, you need to deliver it to end users. Java 25 provides two powerful tools for this:
- jlink: Creates custom runtime images with only the modules you need
- jpackage: Packages those images into native installers (pkg, deb, msi)
Together, these tools reduce deployment size, improve startup time, and provide a seamless installation experience across platforms.
Why Custom Runtime Images?
The Problem with Standard JDK Distribution
Shipping your application with the full JDK is wasteful:
- Full JDK 25: ~300+ MB
- Most applications use only a fraction of standard modules
- Bloats container images, increases download time, expands attack surface
Custom Runtime Image Benefits
- Smaller Size: Typically 50-100 MB (vs 300+ MB)
- Faster Startup: Fewer modules to initialize
- Reduced Attack Surface: Exclude unused modules like Swing, SQL, or XML parsers
- Reproducibility: Same source + jlink → identical binary image
jlink: Building Custom Runtime Images
Finding Your Module Dependencies
First, identify which modules your application requires:
# Create a JAR of your application
mvn clean package
# Analyze dependencies
jdeps --print-module-deps \
--ignore-missing-deps \
target/myapp-1.0.0.jar
Output:
java.base,java.logging,java.net.http,java.sql
Creating a Runtime Image
Build a custom runtime with only those modules:
jlink \
--module-path $JAVA_HOME/jmods:target/mods \
--add-modules java.base,java.logging,java.net.http,java.sql \
--strip-debug \
--no-header-files \
--no-man-pages \
--compress=2 \
--output build/runtime
jlink Options Explained
| Option | Purpose |
|---|---|
--module-path |
Where to find modules (JDK + your app) |
--add-modules |
Modules to include (comma-separated list) |
--strip-debug |
Remove debug symbols to reduce size |
--no-header-files |
Exclude C headers (not needed at runtime) |
--no-man-pages |
Exclude manual pages |
--compress=2 |
ZIP compression level (0=none, 2=max) |
--output |
Directory for the custom runtime |
Using the Custom Runtime
# Run your application with the custom runtime
./build/runtime/bin/java -m com.example.app
# or with a main JAR
./build/runtime/bin/java -jar target/myapp.jar
Practical Example: Build Script
Create scripts/build-runtime.sh:
#!/bin/bash
set -e
# Build the application
mvn clean package -DskipTests
# Find module dependencies
MODULES=$(jdeps --print-module-deps \
--ignore-missing-deps \
target/myapp-1.0.0.jar)
echo "Required modules: $MODULES"
# Create custom runtime
jlink \
--module-path $JAVA_HOME/jmods \
--add-modules $MODULES \
--strip-debug \
--no-header-files \
--no-man-pages \
--compress=2 \
--output build/runtime
echo "Runtime image created: build/runtime"
ls -lh build/runtime
jpackage: Creating Native Installers
jpackage bundles your application and custom runtime into platform-specific installers that end users can install like any other application.
Prerequisites
- macOS: Xcode Command Line Tools
- Linux: fakeroot and binutils (for deb), rpmbuild (for rpm)
- Windows: Visual Studio Build Tools or WiX Toolset 3.11+
Install on macOS:
xcode-select --install
Install on Linux (Ubuntu):
sudo apt-get install binutils fakeroot
Basic jpackage Command
jpackage \
--type pkg \
--name MyApp \
--app-version 1.0.0 \
--input build/dist \
--main-jar myapp.jar \
--runtime-image build/runtime \
--vendor "Example Inc." \
--copyright "© 2025 Example Inc." \
--license-file LICENSE
macOS Package (.pkg)
jpackage \
--type pkg \
--name MyApp \
--app-version 1.0.0 \
--input build/dist \
--main-jar myapp-1.0.0.jar \
--runtime-image build/runtime \
--icon src/main/resources/icon.icns \
--vendor "Example Inc." \
--copyright "© 2025 Example Inc." \
--license-file LICENSE \
--output build/packages
Result: MyApp-1.0.0.pkg installable via double-click
macOS Signing and Notarization
For distribution on the Mac App Store or outside development:
# Sign the app bundle before packaging
codesign --sign "Developer ID Application: Your Name" \
--deep --force build/MyApp.app
# Package after signing
jpackage ...options...
# Notarize with Apple
xcrun notarytool submit build/MyApp-1.0.0.pkg \
--apple-id "your-email@example.com" \
--password "app-specific-password" \
--team-id "TEAM1234AB"
Linux Debian Package (.deb)
jpackage \
--type deb \
--name myapp \
--app-version 1.0.0 \
--input build/dist \
--main-jar myapp-1.0.0.jar \
--runtime-image build/runtime \
--icon src/main/resources/icon.png \
--vendor "Example Inc." \
--copyright "© 2025 Example Inc." \
--license-file LICENSE \
--linux-menu-group "Utilities" \
--linux-shortcut \
--output build/packages
Result: myapp-1.0.0.deb installable via sudo apt install ./myapp-1.0.0.deb
Windows MSI Installer (.msi)
jpackage `
--type msi `
--name MyApp `
--app-version 1.0.0 `
--input build\dist `
--main-jar myapp-1.0.0.jar `
--runtime-image build\runtime `
--icon src\main\resources\icon.ico `
--vendor "Example Inc." `
--copyright "© 2025 Example Inc." `
--license-file LICENSE `
--win-dir-chooser `
--win-menu `
--win-shortcut `
--output build\packages
Result: MyApp-1.0.0.msi with standard Windows installer wizard
Complete Build and Package Workflow
Create a Maven build profile in pom.xml:
<profiles>
<profile>
<id>package-app</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.app.App</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
Build script (scripts/release.sh):
#!/bin/bash
set -e
VERSION="1.0.0"
APP_NAME="myapp"
echo "Building application..."
mvn clean package -P package-app -DskipTests
echo "Analyzing module dependencies..."
MODULES=$(jdeps --print-module-deps \
--ignore-missing-deps \
target/${APP_NAME}-${VERSION}.jar)
echo "Creating runtime image..."
jlink \
--module-path $JAVA_HOME/jmods \
--add-modules $MODULES \
--strip-debug \
--no-header-files \
--no-man-pages \
--compress=2 \
--output build/runtime
echo "Staging for packaging..."
mkdir -p build/dist
cp target/${APP_NAME}-${VERSION}.jar build/dist/
echo "Creating native packages..."
jpackage \
--type pkg \
--name MyApp \
--app-version $VERSION \
--input build/dist \
--main-jar ${APP_NAME}-${VERSION}.jar \
--runtime-image build/runtime \
--icon assets/icon.icns \
--vendor "Example Inc." \
--output build/packages
echo "✓ Package created: build/packages/MyApp-${VERSION}.pkg"
Run the release:
bash scripts/release.sh
Distribution Strategies
For End Users (GUI Applications)
- macOS:
.pkgvia installer or direct download - Linux:
.debvia Ubuntu Software Center or apt - Windows:
.msivia installer wizard
For Developers (Microservices)
- Docker: Package custom runtime in container
- Cloud: Deploy JAR + custom runtime to managed platforms
- Kubernetes: Use container images with custom runtime
For Libraries
- Maven Central: Publish JAR only (users provide their own JDK)
- GitHub Releases: Provide pre-built fat JARs for convenience
Best Practices
- Version Your Runtime: Include version in
--app-versionto rebuild when JDK patches release - Use Reproducible Builds: Document exact jlink module list in source control
- Test Installers: Install on clean machines to catch missing dependencies
- Sign Everything: macOS code signing, Windows Authenticode, Linux checksums
- Include License: Always provide
--license-filewith proper attribution - Automate: CI/CD should build installers on every release
Troubleshooting
"Module not found" error
Ensure all dependencies are on --module-path:
jlink --module-path $JAVA_HOME/jmods:target/mods ...
Installer doesn't find main class
Verify --main-jar matches your JAR filename exactly and contains a manifest with Main-Class.
Platform-specific install failures
Test on clean VMs of each platform before production release.
Conclusion
jlink and jpackage transform Java from a "bring your own JDK" runtime into a self-contained, professionally distributed application. Users can install your app like any native application, without needing Java knowledge or separate JDK installation.
Combined with modular architecture (JPMS) and custom runtime images, modern Java applications are:
- Small: 50-100 MB vs 300+ MB with full JDK
- Fast: Quick startup with only needed modules
- Secure: No unused attack surface
- Professional: Native installers across all platforms
Next: Best practices for dependency management, versioning, and deployment strategies.