diff --git a/docs/modules/ROOT/pages/javalib/module-config.adoc b/docs/modules/ROOT/pages/javalib/module-config.adoc
index 2a3ea2a66d1..88a5e4f545a 100644
--- a/docs/modules/ROOT/pages/javalib/module-config.adoc
+++ b/docs/modules/ROOT/pages/javalib/module-config.adoc
@@ -34,7 +34,7 @@ include::partial$example/javalib/module/8-annotation-processors.adoc[]
 
 include::partial$example/javalib/module/9-docjar.adoc[]
 
-
+[[specifying-main-class]]
 == Specifying the Main Class
 
 include::partial$example/javalib/module/11-main-class.adoc[]
@@ -56,3 +56,11 @@ include::partial$example/javalib/module/3-override-tasks.adoc[]
 == Native C Code with JNI
 
 include::partial$example/javalib/module/15-jni.adoc[]
+
+== Java App and JVM Bundle using jlink
+
+include::partial$example/javalib/module/16-jlink.adoc[]
+
+== Java App, JVM Bundle and Installer using jpackage
+
+include::partial$example/javalib/module/17-jpackage.adoc[]
diff --git a/example/javalib/module/16-jlink/build.mill b/example/javalib/module/16-jlink/build.mill
new file mode 100644
index 00000000000..6aae51422cc
--- /dev/null
+++ b/example/javalib/module/16-jlink/build.mill
@@ -0,0 +1,55 @@
+// This example illustrates how to use Mill to generate a runtime image using the `jlink` tool.
+// Starting with JDK 9, `jlink` bundles Java app code with a stripped-down version of the JVM.
+
+//// SNIPPET:BUILD
+package build
+import mill._, javalib._
+import mill.javalib.Assembly._
+import mill.scalalib.JlinkModule
+
+object foo extends JavaModule with JlinkModule {
+  def jlinkModuleName: T[String] = T { "foo" }
+  def jlinkModuleVersion: T[Option[String]] = T { Option("1.0") }
+  def jlinkCompressLevel: T[String] = T { "2" }
+}
+//// SNIPPET:END
+
+// Most of the work is done by the `trait JlinkModule` in two steps:
+
+// 1.0. it uses the `jmod` tool to create a `jlink.jmod` file for the main Java module.
+// The main Java module is typically the module containing the `mainClass`.
+
+// If your build file doesn't explicitly specify a `mainClass`, `JlinkModule` will infer it from `JavaModule`, which is its parent trait.
+// See xref:javalib/module-config.adoc#specifying-main-class[Specifying the Main Class] to learn more on how to influence the inference process.
+// You can explicitly specify a `mainClass` like so in your build file:
+
+//// SNIPPET:BUILD
+// def mainClass: T[Option[String]] = { Option("com.foo.app.Main") }
+//// SNIPPET:END
+
+// 2.0. it then uses the `jlink` tool, to link the previously created `jlink.jmod` with a runtime image.
+
+// With respect to the `jlinkCompressLevel` option, on recent builds of OpenJDK and its descendants,
+// `jlink` will accept [`0`, `1`, `2`] but it will issue a deprecation warning.
+// Valid values on OpenJDK range between: ["zip-0" - "zip-9"].
+
+// NOTE: The version of `jlink` that ships with the Oracle JDK will only accept [`0`, `1`, `2`]
+// as valid values for compression, with `0` being "no compression"
+// and 2 being "ZIP compression".
+
+/** Usage
+
+// To use a specific JDK, first set your `JAVA_HOME` environment variable prior to running the build.
+
+// export JAVA_HOME=/Users/mac/.sdkman/candidates/java/17.0.9-oracle/
+
+> mill foo.jlinkAppImage
+
+> mill show foo.jlinkAppImage
+".../out/foo/jlinkAppImage.dest/jlink-runtime"
+
+> ./out/foo/jlinkAppImage.dest/jlink-runtime/bin/jlink
+... foo.Bar main
+INFO: Hello World!
+
+*/
diff --git a/example/javalib/module/16-jlink/foo/resources/application.conf b/example/javalib/module/16-jlink/foo/resources/application.conf
new file mode 100644
index 00000000000..9341faacf2a
--- /dev/null
+++ b/example/javalib/module/16-jlink/foo/resources/application.conf
@@ -0,0 +1 @@
+Bar Application Conf
diff --git a/example/javalib/module/16-jlink/foo/src/foo/Bar.java b/example/javalib/module/16-jlink/foo/src/foo/Bar.java
new file mode 100644
index 00000000000..a87ef2b02ad
--- /dev/null
+++ b/example/javalib/module/16-jlink/foo/src/foo/Bar.java
@@ -0,0 +1,11 @@
+package foo;
+
+import java.util.logging.Logger;
+
+public class Bar {
+  private static final Logger LOG = Logger.getLogger(Bar.class.getName());
+
+  public static void main(String[] args) {
+    LOG.info("Hello World!");
+  }
+}
diff --git a/example/javalib/module/16-jlink/foo/src/module-info.java b/example/javalib/module/16-jlink/foo/src/module-info.java
new file mode 100644
index 00000000000..17b00987f29
--- /dev/null
+++ b/example/javalib/module/16-jlink/foo/src/module-info.java
@@ -0,0 +1,3 @@
+module foo {
+  requires java.logging;
+}
diff --git a/example/javalib/module/17-jpackage/bar/resources/application.conf b/example/javalib/module/17-jpackage/bar/resources/application.conf
new file mode 100644
index 00000000000..9341faacf2a
--- /dev/null
+++ b/example/javalib/module/17-jpackage/bar/resources/application.conf
@@ -0,0 +1 @@
+Bar Application Conf
diff --git a/example/javalib/module/17-jpackage/build.mill b/example/javalib/module/17-jpackage/build.mill
new file mode 100644
index 00000000000..ff88bb8bc8f
--- /dev/null
+++ b/example/javalib/module/17-jpackage/build.mill
@@ -0,0 +1,107 @@
+// This example illustrates how to use Mill to generate a native package/installer
+// using the `jpackage` tool.
+
+//// SNIPPET:BUILD
+package build
+import mill._, javalib._
+import mill.javalib.Assembly._
+import mill.scalalib.JpackageModule
+
+object foo extends JavaModule with JpackageModule {
+  def jpackageType = "app-image"
+
+  def assemblyRules = Seq(
+    // all application.conf files will be concatenated into single file
+    Rule.Append("application.conf"),
+    // all *.conf files will be concatenated into single file
+    Rule.AppendPattern(".*\\.conf")
+  )
+}
+//// SNIPPET:END
+
+// JPMS (Java Platform Module System) is a modern distribution format that was designed
+// to avoid several of the shortcomings of the ubiquitous JAR format, especially "JAR Hell".
+
+// A defining characteristic of module-based Java applications based on the JPMS format
+// is that a `module-info.java` must be defined at the root of the module’s source file hierarchy.
+// The `module-info.java` must explicitly list modules that it depends on, and also list
+// packages that it exports, to make the integrity of these relationships easy to verify,
+// both at compile-time and run-time.
+
+// Starting with version 14, the JDK ships with the `jpackage` tool which can
+// assemble any module-based Java application into a native package/installer.
+
+// The above build file expects the following project layout:
+//
+//// SNIPPET:TREE
+//
+// ----
+// build.mill
+// foo/
+//     src/
+//         Foo.java
+//         Bar.java
+//
+//     module-info.java
+// ----
+//
+//// SNIPPET:END
+
+// The build defines a `foo` module that uses the `trait JpackageModule`.
+
+// NOTE: The term `Module` is also used in Mill to refer to xref:fundamentals/modules.adoc[traits].
+// This is not to be confused with Java app code structured as modules according to the JPMS format.
+
+// The `JpackageModule` trait will infer most of the options needed to assemble a native
+// package/installer, but you can still customize its output. In our example, we specified:
+
+// def jpackageType = "pkg"
+
+// This tells `jpackage` to generate a `.pkg`, which is the native installer format on macOS.
+// Valid values on macOS are: `dmg`, `pkg` and `app-image`.
+
+// NOTE: `jpackage` doesn't not support cross-targeting. Cross-targeting in this
+// context means the `jpackage` binary shipped with a macOS JDK
+// cannot be used to produce a native installer for another OS like Windows or Linux.
+
+/** Usage
+
+> mill foo.assembly
+
+> mill show foo.assembly
+".../out/foo/assembly.dest/out.jar"
+
+> java -jar ./out/foo/assembly.dest/out.jar
+INFO: Loaded application.conf from resources: Foo Application Conf
+INFO: Hello World application started successfully
+
+> mill foo.jpackageAppImage
+
+> mill show foo.jpackageAppImage
+".../out/foo/jpackageAppImage.dest/image"
+*/
+
+// On macOS, `jpackageType` accepts 3 values: "dmg" or "pkg" or "app-image" (default).
+
+// Setting `def jpackageType = "dmg"` will produce:
+// ----
+// ls -l ./out/foo/jpackageAppImage.dest/image
+// ... foo-1.0.dmg
+// ----
+
+// Setting `def jpackageType = "pkg"` will produce:
+// ----
+// ls -l ./out/foo/jpackageAppImage.dest/image
+// ... foo-1.0.pkg
+// ----
+
+// Setting `def jpackageType = "app-image"` will produce:
+// ----
+// ls -l ./out/foo/jpackageAppImage.dest/image
+// ... foo.app/
+// ./out/foo/jpackageAppImage.dest/image/foo.app/Contents/MacOS/foo
+// ... foo.Foo readConf
+// INFO: Loaded application.conf from resources: Foo Application Conf
+// ... foo.Bar ...
+// INFO: Hello World application started successfully
+// ----
diff --git a/example/javalib/module/17-jpackage/foo/resources/application.conf b/example/javalib/module/17-jpackage/foo/resources/application.conf
new file mode 100644
index 00000000000..4f562e845c8
--- /dev/null
+++ b/example/javalib/module/17-jpackage/foo/resources/application.conf
@@ -0,0 +1 @@
+Foo Application Conf
\ No newline at end of file
diff --git a/example/javalib/module/17-jpackage/foo/src/foo/Bar.java b/example/javalib/module/17-jpackage/foo/src/foo/Bar.java
new file mode 100644
index 00000000000..510bb668dd7
--- /dev/null
+++ b/example/javalib/module/17-jpackage/foo/src/foo/Bar.java
@@ -0,0 +1,69 @@
+package foo;
+
+import static foo.Foo.LOGGER;
+
+import java.awt.*;
+import java.io.IOException;
+import javax.swing.*;
+
+public class Bar {
+
+  public static boolean isCI() {
+    String[] ciEnvironments = {
+      "CI",
+      "CONTINUOUS_INTEGRATION",
+      "JENKINS_URL",
+      "TRAVIS",
+      "CIRCLECI",
+      "GITHUB_ACTIONS",
+      "GITLAB_CI",
+      "BITBUCKET_PIPELINE",
+      "TEAMCITY_VERSION"
+    };
+
+    for (String env : ciEnvironments) {
+      if (System.getenv(env) != null) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  public static void main(String[] args) throws IOException {
+    // Needed because Swing GUIs don't work in headless CI environments
+    if (isCI()) {
+      Foo.readConf();
+      LOGGER.info("Hello World application started successfully");
+      System.exit(0);
+    }
+
+    // Use SwingUtilities.invokeLater to ensure thread safety
+    SwingUtilities.invokeLater(() -> {
+      try {
+        // Set a modern look and feel
+        UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
+
+        // Create the main window
+        JFrame frame = new JFrame("Hello World");
+        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+        frame.setSize(300, 200);
+        frame.setLocationRelativeTo(null);
+
+        // Create a label from the application.conf
+        JLabel label = new JLabel(Foo.readConf(), SwingConstants.CENTER);
+        label.setFont(new Font("Arial", Font.BOLD, 16));
+
+        // Add the label to the frame
+        frame.getContentPane().add(label);
+
+        // Make the frame visible
+        frame.setVisible(true);
+
+        LOGGER.info("Hello World application started successfully");
+      } catch (Exception e) {
+        LOGGER.severe("Error initializing application: " + e.getMessage());
+      }
+    });
+  }
+}
diff --git a/example/javalib/module/17-jpackage/foo/src/foo/Foo.java b/example/javalib/module/17-jpackage/foo/src/foo/Foo.java
new file mode 100644
index 00000000000..f839da85062
--- /dev/null
+++ b/example/javalib/module/17-jpackage/foo/src/foo/Foo.java
@@ -0,0 +1,32 @@
+package foo;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.logging.Handler;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import java.util.logging.SimpleFormatter;
+
+public class Foo {
+  public static final Logger LOGGER = Logger.getLogger(Foo.class.getName());
+
+  static {
+    // Configure the logger to use a custom formatter
+    for (Handler handler : LOGGER.getParent().getHandlers()) {
+      handler.setFormatter(new SimpleFormatter() {
+        @Override
+        public String format(LogRecord record) {
+          // Return the log level, message, but omit the timestamp
+          return String.format("%s: %s%n", record.getLevel(), record.getMessage());
+        }
+      });
+    }
+  }
+
+  public static String readConf() throws IOException {
+    InputStream inputStream = Foo.class.getClassLoader().getResourceAsStream("application.conf");
+    String conf = new String(inputStream.readAllBytes());
+    LOGGER.info("Loaded application.conf from resources: " + conf);
+    return conf;
+  }
+}
diff --git a/example/javalib/module/17-jpackage/foo/src/module-info.java b/example/javalib/module/17-jpackage/foo/src/module-info.java
new file mode 100644
index 00000000000..57513ced7a6
--- /dev/null
+++ b/example/javalib/module/17-jpackage/foo/src/module-info.java
@@ -0,0 +1,4 @@
+module foo {
+  requires java.logging;
+  requires java.desktop;
+}
diff --git a/scalalib/src/mill/scalalib/JavaModule.scala b/scalalib/src/mill/scalalib/JavaModule.scala
index 3a2d71ad813..1c901525557 100644
--- a/scalalib/src/mill/scalalib/JavaModule.scala
+++ b/scalalib/src/mill/scalalib/JavaModule.scala
@@ -360,6 +360,13 @@ trait JavaModule
     T.traverse(transitiveModuleRunModuleDeps)(_.localClasspath)().flatten
   }
 
+  /**
+   * Almost the same as [[transitiveLocalClasspath]], but using the [[jar]]s instead of [[localClasspath]].
+   */
+  def transitiveJars: T[Seq[PathRef]] = Task {
+    T.traverse(transitiveModuleCompileModuleDeps)(_.jar)()
+  }
+
   /**
    * Same as [[transitiveLocalClasspath]], but with all dependencies on [[compile]]
    * replaced by their non-compiling [[bspCompileClassesPath]] variants.
diff --git a/scalalib/src/mill/scalalib/JlinkModule.scala b/scalalib/src/mill/scalalib/JlinkModule.scala
new file mode 100644
index 00000000000..ff0ebe0e48c
--- /dev/null
+++ b/scalalib/src/mill/scalalib/JlinkModule.scala
@@ -0,0 +1,103 @@
+package mill
+package scalalib
+
+import mill._
+import mill.util.Jvm
+
+/**
+ * Support building modular runtime images with the `jlink` tool, which is included in JDK 9 and later.
+ *
+ * The official `jlink` docs: https://docs.oracle.com/en/java/javase/23/docs/specs/man/jlink.html
+ */
+trait JlinkModule extends JavaModule {
+
+  /** The base name for the runtime image */
+  def jlinkImageName: T[String] = T { "jlink" }
+
+  /** Name of the main module to be included in the runtime image */
+  def jlinkModuleName: T[String] = T { "" }
+
+  /** The main module's version number. */
+  def jlinkModuleVersion: T[Option[String]] = T { None }
+
+  /** The main class to use as the runtime entry point. */
+  def jlinkMainClass: T[String] = T { finalMainClass() }
+
+  /**
+   * Compress level for the runtime image.
+   * On newer versions of OpenJDK, valid values range between:
+   *  "zip-0" (no compression) and "zip-9" (best compression).
+   *
+   * On all versions of Oracle's JDK, valid values range between:
+   *  0 (no compression), 1 (constant string sharing) and 2 (ZIP).
+   *
+   * Assumes you are on a recent OpenJDK version thus defaults to "zip-6".
+   */
+  def jlinkCompressLevel: T[String] = T { "zip-6" }
+
+  /**
+   * Creates a Java module file (.jmod) from compiled classes
+   */
+  def jmodPackage: T[PathRef] = T {
+
+    val mainClass: String = finalMainClass()
+    val outputPath = T.dest / "jlink.jmod"
+
+    val libs = T.dest / "libs"
+    val cp = runClasspath().map(_.path)
+    val jars = cp.filter(os.exists).zipWithIndex.map { case (p, idx) =>
+      val dest = libs / s"${p.last}"
+      os.copy(p, dest, createFolders = true)
+      dest
+    }
+
+    val classPath = jars.map(_.toString).mkString(sys.props("path.separator"))
+    val args = {
+      val baseArgs = Seq(
+        Jvm.jdkTool("jmod", this.zincWorker().javaHome().map(_.path)),
+        "create",
+        "--class-path",
+        classPath.toString,
+        "--main-class",
+        mainClass,
+        "--module-path",
+        classPath.toString,
+        outputPath.toString
+      )
+
+      val versionArgs = jlinkModuleVersion().toSeq.flatMap { version =>
+        Seq("--module-version", version)
+      }
+
+      baseArgs ++ versionArgs
+    }
+    os.proc(args).call()
+
+    PathRef(outputPath)
+  }
+
+  /** Builds a custom runtime image using jlink */
+  def jlinkAppImage: T[PathRef] = T {
+    val modulePath = jmodPackage().path.toString
+    val outputPath = T.dest / "jlink-runtime"
+
+    val args = Seq(
+      Jvm.jdkTool("jlink", this.zincWorker().javaHome().map(_.path)),
+      "--launcher",
+      s"${jlinkImageName()}=${jlinkModuleName()}/${jlinkMainClass()}",
+      "--module-path",
+      modulePath,
+      "--add-modules",
+      jlinkModuleName(),
+      "--output",
+      outputPath.toString,
+      "--compress",
+      jlinkCompressLevel().toString,
+      "--no-header-files",
+      "--no-man-pages"
+    )
+    os.proc(args).call()
+
+    PathRef(outputPath)
+  }
+}
diff --git a/scalalib/src/mill/scalalib/JpackageModule.scala b/scalalib/src/mill/scalalib/JpackageModule.scala
new file mode 100644
index 00000000000..af6c47908bd
--- /dev/null
+++ b/scalalib/src/mill/scalalib/JpackageModule.scala
@@ -0,0 +1,84 @@
+package mill
+package scalalib
+
+import mill._
+import mill.util.Jvm
+
+/**
+ * Support for building a native package / installer with the `jpackage` tool which comes bundled with JDK 14 and later.
+ *
+ * The official `jpackage` docs: https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html
+ */
+trait JpackageModule extends JavaModule {
+
+  /** The application name */
+  def jpackageName: T[String] = T { artifactName() }
+
+  /** The main class to use as the entry point to the native package / installer. */
+  def jpackageMainClass: T[String] = T { finalMainClass() }
+
+  /**
+   * The type of native package / installer to be created.
+   *
+   * Valid values are:
+   *  "app-image" - any OS
+   *  "dmg", "pkg" - macOS (native package, installer)
+   *  "exe", "msi" - Windows (native package, installer)
+   *  "rpm", "deb" - Linux
+   *
+   * If unspecified, defaults to "app-image" which will build a package native to the host platform.
+   */
+  def jpackageType: T[String] = T { "app-image" }
+
+  /**
+   * The classpath used for the `jpackage` tool. The first entry needs to be the main jar.
+   * In difference to [[runClasspath]], it contains the built jars of all dependent modules.
+   */
+  def jpackageRunClasspath: T[Seq[PathRef]] = T {
+    val recLocalClasspath = (localClasspath() ++ transitiveLocalClasspath()).map(_.path)
+
+    val runCp = runClasspath().filterNot(pr => recLocalClasspath.contains(pr.path))
+
+    val mainJar = jar()
+    val recJars = transitiveJars()
+
+    mainJar +: (recJars ++ runCp)
+  }
+
+  /** Builds a native package of the main application. */
+  def jpackageAppImage: T[PathRef] = T {
+    // materialize all jars into a "lib" dir
+    val libs = T.dest / "lib"
+    val cp = jpackageRunClasspath().map(_.path)
+    val jars = cp.filter(os.exists).zipWithIndex.map { case (p, idx) =>
+      val dest = libs / s"${idx + 1}-${p.last}"
+      os.copy(p, dest, createFolders = true)
+      dest
+    }
+
+    val appName = jpackageName()
+    val appType = jpackageType()
+    val mainClass = jpackageMainClass()
+    val mainJarName = jars.head.last
+
+    val args: Seq[String] = Seq(
+      Jvm.jdkTool("jpackage", this.zincWorker().javaHome().map(_.path)),
+      "--type",
+      appType,
+      "--name",
+      appName,
+      "--input",
+      libs.toString(),
+      "--main-jar",
+      mainJarName,
+      "--main-class",
+      mainClass
+    )
+
+    // run jpackage tool
+    val outDest = T.dest / "image"
+    os.makeDir.all(outDest)
+    os.proc(args).call(cwd = outDest)
+    PathRef(outDest)
+  }
+}