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

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
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: .pkg via installer or direct download
  • Linux: .deb via Ubuntu Software Center or apt
  • Windows: .msi via 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

  1. Version Your Runtime: Include version in --app-version to rebuild when JDK patches release
  2. Use Reproducible Builds: Document exact jlink module list in source control
  3. Test Installers: Install on clean machines to catch missing dependencies
  4. Sign Everything: macOS code signing, Windows Authenticode, Linux checksums
  5. Include License: Always provide --license-file with proper attribution
  6. 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.