<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>JPMS on Sven Ruppert</title><link>https://svenruppert.com/tags/jpms/</link><description>Sven Ruppert — Java Veteran, Speaker, Trainer &amp; Bushcrafter. Articles, talks, workshops and videos on Core Java, Cybersecurity, Vaadin and Developer Relations.</description><generator>Hugo</generator><language>en</language><managingEditor>sven.ruppert@gmail.com (Sven Ruppert)</managingEditor><webMaster>sven.ruppert@gmail.com (Sven Ruppert)</webMaster><copyright>© 2026 Sven Ruppert</copyright><atom:link href="https://svenruppert.com/tags/jpms/index.xml" rel="self" type="application/rss+xml"/><image><url>https://svenruppert.com/img/sven-ruppert.jpg</url><title>Sven Ruppert</title><link>https://svenruppert.com/tags/jpms/</link></image><lastBuildDate>Thu, 24 Sep 2026 10:00:00 +0200</lastBuildDate><item><title>Vaadin Deployment on Hetzner — Part 6: Self-contained Vaadin with jlink</title><link>https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink/</link><pubDate>Thu, 24 Sep 2026 10:00:00 +0200</pubDate><author>sven.ruppert@gmail.com (Sven Ruppert)</author><dc:creator>Sven Ruppert</dc:creator><guid isPermaLink="true">https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink/</guid><description>A self-contained Vaadin distribution with jlink: 16 modules, 65 instead of 309 MB, and what the smaller runtime really changes at startup.</description><content:encoded>&lt;![CDATA[<p>Sixth and final part of the series<em>Vaadin – Deployment on Hetzner</em>. The application now brings along not only Jetty but also its own Java runtime: 16 modules instead of 68, 65 instead of 309 MB — and a module list that cannot be fully derived. All figures come from an actual run on a Debian 13 server.</p><h2 id="the-state-after-part-5">The state after Part 5</h2><p>For five parts, the boundary between application and machine has been moving in
one direction. Part 1 handed a WAR to a Jetty that someone else had installed.
Part 3 turned that Jetty into a library of the application. Part 4 packaged the
result, Part 5 shipped it and demonstrated the rollback.</p><p>What sits on the reference server after Part 5 looks like this:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>/opt/vaadinapp/
├── current -&gt; releases/2026-09-09-thin
├── logs/
└── releases/
├── 2026-09-09-thin/
│ ├── VERSION
│ ├── bin/start.sh
│ ├── app/vaadinapp.jar 16 KB
│ ├── lib/ 127 archives, 29 MB
│ └── sbom.json
└── 2026-09-09-fat/</code></pre></div><p>And next to it, outside this directory:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>/var/lib/vaadinapp/.sdkman/candidates/java/current -&gt; 26.0.2+1.1-tem 309 MB</code></pre></div><p><strong>This is the last remnant.</strong> A Java, installed and maintained by someone else,
309 MB in size, eleven times as large as the application it runs. The systemd
unit reaches it through a symlink — the same mechanism that Part 1 used for
Jetty and that Jetty has not needed since Part 3.</p><h3 id="what-this-part-changes">What this part changes</h3><figure><img src="/images/2026/09/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink-teil06-laufzeit.png" alt="The last expectation placed on the machine" loading="lazy" decoding="async"/><p><em>Figure 1: The same release, once without and once with its own runtime.</em></p><p><code>jlink</code> takes the modules of a JDK and produces a runtime image that contains
only what this one application calls. The image goes into the release, next to<code>app/</code> and<code>lib/</code>. The start script finds it there, and the server no longer
needs an installed Java.</p><p>On the reference server that means<strong>65 instead of 309 MB</strong> and<strong>16 instead of
68 modules</strong>. The image contains two executables:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>runtime/bin/
├── java
└── keytool</code></pre></div><p>No<code>javac</code>, no<code>jdeps</code>, no<code>jlink</code>, no<code>jcmd</code>, no<code>jstack</code>. What sits on the
server can start the application and manage keystores — nothing else. That is
the property Chapter 10 discusses as a security gain, and it is a by-product:
nobody removed the tools, they were never part of what the application needs.</p><h3 id="what-this-part-does-not-change">What this part does not change</h3><p>Three things stay as they are, and it is worth saying so up front.</p><p><strong>The systemd unit.</strong><code>ExecStart</code> still points to<code>current/bin/start.sh</code>. No
path, no variable, not a single line changes. The trial run on the server
executed with<code>JAVA_HOME</code> unset and found its JVM anyway.</p><p><strong>The application.</strong> No<code>module-info.java</code>, no restructuring, no new dependency.
Chapter 3 explains why that is not a coincidence but jlink&rsquo;s design.</p><p><strong>The recommendation from Part 5.</strong> Anyone who chose the fat JAR can continue
just the same; the image sits next to it, not inside it.</p><h3 id="the-code-state-for-this-part">The code state for this part</h3><p>Tag<strong><code>teil-06</code></strong>. Compared to<code>teil-04</code> there are two changes: one in the
start script, one in the assembly of the distribution. Both appear in Chapter 7.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/svenruppert/Blog-Vaadin-Deployment-on-Hetzner</span></span><span class="line"><span class="cl"><span class="nb">cd</span> Blog-Vaadin-Deployment-on-Hetzner</span></span><span class="line"><span class="cl">git checkout teil-06</span></span></code></pre></div></div><p>The module this part works in is<code>embedded-jetty</code> — it carries the<code>main()</code>
and the assembly that builds the distribution.</p><h2 id="why-a-runtime-of-your-own">Why a runtime of your own?</h2><p>The obvious answer is: because the image is smaller. It is correct and at the
same time the weakest of the three answers.</p><h3 id="size">Size</h3><p>65 instead of 309 MB is 79% less. That sounds impressive and means little in
practice: a server with a 40 GB disk does not notice the difference, and the
transfer over the network barely registers next to the 29 MB of application
libraries.</p><p>Where size does matter is a different setting — wherever the image is<strong>multiplied</strong>. A container image that carries the full JDK carries it in
every layer, in every registry, on every node. For this series that is no
argument, because no container runs here; Chapter 11 comes back to it.</p><h3 id="attack-surface">Attack surface</h3><p>A full Temurin 26 ships 68 modules, 22 from the<code>java.</code> namespace and 46 from
the<code>jdk.</code> namespace. This application calls 16 of them. The remaining<strong>52</strong> are
code that sits on the server, can be loaded, and is never needed — among them
a compiler, a scripting tool, and the entire toolchain for process
inspection.</p><p>That is not a theoretical argument. Anyone who has compromised an application
far enough to execute code finds a complete development environment on a
server with a full JDK. On one with a jlink image, they find<code>java</code> and<code>keytool</code>.</p><p>The second half of the same argument concerns vulnerability reports. A
CVE against a module that is not contained in the image does not affect this
release — verifiably so, not as a matter of judgment. Chapter 10 shows how
to prove it.</p><h3 id="the-division-of-responsibility">The division of responsibility</h3><p>This is the answer that carries this series.</p><p>As long as a Java is installed on the server, there are two places where
decisions about the application&rsquo;s runtime are made: the release and the machine.
A release built and tested with Java 26 runs on a machine whose Java someone
has raised to 27. Maybe it works. Nobody has verified it, because the two
decisions were made in different places.</p><p>With its own image, the Java version is<strong>part of the release</strong> and travels with
it — forward on update, backward on rollback. The rollback from Part 5,
Chapter 5 takes the runtime along without anything having to change for it.</p><p>It is the same idea that carried Part 3. There it was about Jetty: a server
whose Jetty someone else maintains has a second place where decisions about
the application&rsquo;s behavior are made. Part 6 applies it to the last remaining
component.</p><h3 id="what-speaks-against-it">What speaks against it</h3><p>Three things, and they are not small.</p><p><strong>The build becomes platform-bound.</strong> An image for Linux x86-64 can only be
produced on Linux x86-64. Chapter 6 describes what that means for the build
process and concedes that the solution on the reference server is a compromise.</p><p><strong>Java updates become releases.</strong> A security update of the JDK used to be<code>sdk install</code>,<code>sdk default</code>, restart — three steps without a new build. With
your own image it is a new release. Chapter 10 works through the numbers.</p><p><strong>The module list has to be right.</strong> And it cannot be fully derived.
That is the core of Chapters 4 and 5 and the actual reason this part is
longer than the tool suggests.</p><h2 id="what-jlink-does--and-what-it-does-not-require">What jlink does — and what it does not require</h2><p>A misconception circulates about<code>jlink</code> that is persistent enough to derail
entire projects: that you have to modularize your application to use
it. That is wrong, and the distinction is worth clearing up before the first
line of tool invocation appears.</p><h3 id="two-class-paths-that-have-nothing-to-do-with-each-other">Two class paths that have nothing to do with each other</h3><p>Since Java 9 there have been two ways for the JVM to find classes.</p><p>The<strong>module path</strong> (<code>--module-path</code>) carries modules: archives with a<code>module-info.class</code> that declare what they export and what they require.
The<strong>class path</strong> (<code>-cp</code>) carries everything else — it works the way it has
worked since Java 1.0, and everything that sits on it ends up in the so-called
unnamed module.</p><p><code>jlink</code> operates exclusively on the first. It builds an image from<strong>platform modules</strong> —<code>java.base</code>,<code>java.sql</code>,<code>jdk.zipfs</code>, and so on. What the
application brings along does not interest it.</p><p>And that is exactly why nothing changes about the application:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">sh</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">exec</span><span class="s2">"</span><span class="nv">$JAVA_BIN</span><span class="s2">"</span><span class="nv">$APP_OPTS</span><span class="nv">$JAVA_OPTS</span> -cp<span class="s2">"</span><span class="nv">$CLASSPATH</span><span class="s2">"</span> com.svenruppert.flow.Application<span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span></span></span></code></pre></div></div><p>That is the start line from Part 4, unchanged. The class path still points to<code>app/*:lib/*</code>, the 127 archives still carry no<code>module-info.class</code>, and the
JVM still loads them as the unnamed module. The only thing that changes is<strong>which JVM</strong> that is.</p><div class="pull-quote"><p><strong>The sentence that matters:</strong><code>jlink</code> trims the platform, not the
application. An image of 16 modules runs a class path of 127
non-modular archives without any of them knowing about it.</p></div><h3 id="why-the-story-is-often-told-differently">Why the story is often told differently</h3><p>Because<code>jlink</code> demands a module list, and the obvious way to obtain a
complete module list is to make the application itself modular.
Then<code>module-info.java</code> writes down what it needs, and<code>jlink</code> can follow that declaration.</p><p>Anyone who does not modularize the application — and with 127 dependencies,
nobody does — has to determine the list some other way. That is what<code>jdeps</code> is
for, that is what Chapter 4 is for, and that is what the uncomfortable finding
in Chapter 5 is for.</p><p><strong>So the effort does not disappear, it shifts.</strong> You save yourself the
restructuring of the application and pay with a list that has to be maintained.</p><h3 id="what-ends-up-in-the-image">What ends up in the image</h3><p><code>jlink</code> takes the named modules, resolves their<code>requires</code> relationships, and
writes the result out as a runtime image. On the reference server, 10 named
modules become 16:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>java.base java.datatransfer java.desktop
java.instrument java.logging java.management
java.naming java.net.http java.prefs
java.security.jgss java.security.sasl java.sql
java.transaction.xa java.xml jdk.unsupported
jdk.zipfs</code></pre></div><p>The six unnamed ones come in transitively:<code>java.desktop</code> requires<code>java.datatransfer</code>,<code>java.sql</code> requires<code>java.transaction.xa</code> and<code>java.xml</code>,<code>java.security.jgss</code> requires<code>java.security.sasl</code>. You name the roots; the
resolver takes care of the rest.</p><p><code>java.desktop</code> in a server application looks like a mistake but is
correct: that is where<code>java.beans</code> lives, and<code>flow-server</code>,<code>flow-data</code>,
and Jetty&rsquo;s servlet layer use it. Chapter 4 shows how to verify that.</p><h3 id="what-does-not-end-up-in-the-image">What does not end up in the image</h3><p>Everything else — and that is more than the number 60 suggests:</p><table><thead><tr><th>missing</th><th>what no longer works</th></tr></thead><tbody><tr><td><code>jdk.compiler</code></td><td>compiling at run time</td></tr><tr><td><code>jdk.jshell</code>,<code>jdk.scripting.nashorn</code></td><td>running scripts</td></tr><tr><td><code>jdk.attach</code>,<code>jdk.jcmd</code>,<code>jdk.jfr</code></td><td>attaching to other JVMs, Flight Recorder</td></tr><tr><td><code>java.rmi</code>,<code>jdk.jdi</code></td><td>remote calls, remote diagnostics</td></tr><tr><td><code>jdk.jlink</code>,<code>jdk.jdeps</code></td><td>building another image from this one</td></tr></tbody></table><p>The last row has a practical consequence that Chapter 6 runs into on the
reference server:<strong>a jlink image cannot reproduce itself.</strong> Whoever has the
image cannot build a new one; that takes the JDK.</p><p>The third row is the one you miss before you need it. Anyone used to tackling
a hanging service with<code>jcmd</code> or<code>jstack</code> will not find the tool
in the image.</p><p>It can be retrofitted, and the price is lower than caution suggests.
With<code>jdk.jcmd</code> added to the module list:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>without jdk.jcmd 65,992 KB 16 modules bin: java keytool
with jdk.jcmd 66,292 KB 19 modules bin: java jcmd jinfo jmap jps jstack jstat keytool</code></pre></div><p><strong>300 KB for six tools.</strong> That is not a question of size but a trade-off
between diagnostic capability and attack surface — and it is one to decide
deliberately, not to catch up on during the first incident. For the reference
server it stays at the 16 modules: whatever needs diagnosing is in the
journal, and a release without diagnostic tools can, if in doubt, be swapped
for one with them — that is what the symlink from Part 5 is for.</p><h2 id="determining-the-required-platform-modules">Determining the required platform modules</h2><p><code>jlink</code> demands a list. The application does not keep one. So someone has to
produce it — and the tool for that is called<code>jdeps</code>.</p><h3 id="the-invocation">The invocation</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">jdeps --print-module-deps<span class="se">\</span></span></span><span class="line"><span class="cl"> --ignore-missing-deps<span class="se">\</span></span></span><span class="line"><span class="cl"> --multi-release<span class="m">26</span><span class="se">\</span></span></span><span class="line"><span class="cl"> --class-path<span class="s2">"current/lib/*"</span><span class="se">\</span></span></span><span class="line"><span class="cl"> current/app/vaadinapp.jar</span></span></code></pre></div></div><p>Four switches, and three of them need explaining.</p><p><strong><code>--print-module-deps</code></strong> switches the output from a report to a
list — exactly the form<code>--add-modules</code> expects. Without this switch
you get a multi-page breakdown of who calls whom; with it, a single line.</p><p><strong><code>--ignore-missing-deps</code></strong> is unavoidable on a real class path. 127
archives practically always contain references to classes that are not
shipped — optional integrations with libraries you do not use. Without
this switch,<code>jdeps</code> aborts at the first such spot instead of evaluating
the rest.</p><p>That is the first hint at how this analysis works: it is<strong>allowed</strong> to
have gaps, and it does not tell you so.</p><p><strong><code>--multi-release 26</code></strong> determines which branch of a multi-release
archive<code>jdeps</code> reads. Without it, it refuses to work on every archive with<code>Multi-Release: true</code> in its manifest — and that is the same
manifest entry that Part 4, Chapter 5 dealt with for the fat JAR.</p><h3 id="the-result">The result</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>java.base,java.desktop,java.instrument,java.management,java.naming,java.security.jgss,java.sql</code></pre></div><p>Seven modules. Anyone who does not want to take them on faith can have<code>jdeps</code>
show, in report mode, which archive pulls in which module — here the most
notable culprits for each:</p><table><thead><tr><th>Module</th><th>pulled in by</th></tr></thead><tbody><tr><td><code>java.base</code></td><td>everything</td></tr><tr><td><code>java.desktop</code></td><td><code>flow-server</code>,<code>flow-data</code>,<code>jakarta.el</code>, Jetty&rsquo;s servlet layer</td></tr><tr><td><code>java.instrument</code></td><td><code>org.eclipse.jetty.ee.webapp</code></td></tr><tr><td><code>java.management</code></td><td><code>org.eclipse.jetty.util</code>,<code>org.eclipse.serializer.base</code></td></tr><tr><td><code>java.naming</code></td><td><code>org.eclipse.jetty.jndi</code>,<code>…ee11.annotations</code>, BouncyCastle</td></tr><tr><td><code>java.security.jgss</code></td><td><code>org.eclipse.jetty.client</code>,<code>org.eclipse.jetty.security</code></td></tr><tr><td><code>java.sql</code></td><td><code>flow-data</code>,<code>org.eclipse.jetty.plus</code>, BouncyCastle</td></tr></tbody></table><p>You take these seven, hand them to<code>jlink</code>, get an image, start it — and
get an error.</p><h3 id="the-first-failure">The first failure</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>java.nio.file.ProviderNotFoundException: Provider "jar" not found
at java.base/java.nio.file.FileSystems.newFileSystem(FileSystems.java:341)
at org.eclipse.jetty...</code></pre></div><p>The missing module is<code>jdk.zipfs</code>. Jetty opens archives not only as files
but as<strong>file systems</strong> —<code>FileSystems.newFileSystem(uri, env)</code> with a<code>jar:</code> URI. The provider for that lives in<code>jdk.zipfs</code>, is found via<code>ServiceLoader</code>, and appears in no<code>import</code> statement.</p><p>Added, rebuilt, started — and the next error.</p><h3 id="the-second-failure">The second failure</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>java.lang.NoClassDefFoundError: sun/misc/Unsafe
at org.eclipse.store...</code></pre></div><p><code>jdk.unsupported</code>. EclipseStore uses<code>sun.misc.Unsafe</code> to read and write
objects bypassing serialization — that is the reason it is fast. The module
is called &ldquo;unsupported&rdquo; because it contains access points that are
officially not part of the platform; in the full JDK it is always present anyway,
which is why its absence only shows up here.</p><p>Added, rebuilt, started — and this time it runs.</p><h3 id="nine-modules-one-running-service">Nine modules, one running service</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>systemctl status vaadinapp active (running)
curl -o /dev/null -w '%{http_code}' https://demo.svenruppert.com/ 200
curl -o /dev/null -w '%{http_code}' https://demo.svenruppert.com/login 200</code></pre></div><p>The service runs. Both routes respond. Login works, language
switching works, persistence works.</p><p>This is where the part would end — if the image were complete. It
is not, and the fact that it runs anyway is the most interesting finding of
this series.</p><h2 id="the-three-modules-jdeps-cannot-see">The three modules<code>jdeps</code> cannot see</h2><p>Two modules were supplied by the trial run, each with an error at startup.
The third produces no error.</p><h3 id="what-is-missing">What is missing</h3><p><code>java.net.http</code>. The module with the HTTP client that Java 11 introduced. It
does not appear in the<code>jdeps</code> output, it is missing from the image, and the
service starts, runs, and serves as if everything were fine.</p><h3 id="why-jdeps-does-not-find-it">Why<code>jdeps</code> does not find it</h3><p>Not because of reflection. The call is right there in the code:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="kd">class</span><span class="nc">HibpHolder</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">static</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="n">HaveIBeenPwnedCompromisedPasswordChecker</span><span class="w"/><span class="n">INSTANCE</span><span class="w"/><span class="o">=</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">HaveIBeenPwnedCompromisedPasswordChecker</span><span class="p">.</span><span class="na">usingJdkHttpClient</span><span class="p">(</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">HaveIBeenPwnedCompromisedPasswordChecker</span><span class="p">.</span><span class="na">DEFAULT_ENDPOINT</span><span class="p">,</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">HIBP_TIMEOUT</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>The reason lies elsewhere, and it is a property of the invocation from Chapter 4
that is easy to overlook:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">jdeps ... --class-path<span class="s2">"current/lib/*"</span> current/app/vaadinapp.jar</span></span></code></pre></div></div><p><strong><code>jdeps</code> examines what is given as an argument. The class path serves only
for resolution.</strong> The argument is exactly one archive:<code>app/vaadinapp.jar</code>, 16 KB,
essentially the launcher class. The 127 archives in<code>lib/</code> are consulted to
resolve references — they are not examined.</p><p>And the chain to the HTTP client runs entirely through<code>lib/</code>:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>app/vaadinapp.jar
└─&gt; jCustos-…-core (lib/) application code
└─&gt; PasswordPreflight (lib/) der Lazy Holder oben
└─&gt; jCustos-credentials-hibp-00.83.00.jar (lib/)
└─&gt; java.net.http</code></pre></div><p>Nothing is wrong with the tool, it reports no error, and it answers exactly the
question it was asked.</p><h4 id="the-obvious-switch-does-not-help">The obvious switch does not help</h4><p><code>jdeps</code> has<code>-R</code> for recursive traversal. Measured on the same release:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>without -R: java.base,java.desktop,java.instrument,java.management,
java.naming,java.security.jgss,java.sql
with -R: java.base,java.desktop,java.instrument,java.management,
java.naming,java.security.jgss,java.sql</code></pre></div><p><strong>Identical.</strong> In combination with<code>--print-module-deps</code> and<code>--ignore-missing-deps</code>, the switch changes nothing about the result.</p><h4 id="all-archives-as-arguments--and-the-next-problem">All archives as arguments — and the next problem</h4><p>The way that remains: have every archive examined, not just the one.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">jdeps --print-module-deps --ignore-missing-deps --multi-release<span class="m">26</span><span class="se">\</span></span></span><span class="line"><span class="cl"> current/app/vaadinapp.jar current/lib/*.jar</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>java.base, java.desktop, java.instrument, java.management, java.naming,
java.net.http, java.rmi, java.security.jgss, java.sql, jdk.unsupported</code></pre></div><p>Ten instead of seven.<code>java.net.http</code> is in,<code>jdk.unsupported</code> too. That looks
like the solution, and it is not — for two reasons.</p><p><strong><code>java.rmi</code> is too much.</strong> The shipped image does not contain it and runs.
The culprit is<code>jakarta.transaction</code> — the interface for distributed
transactions references remote calls, and this application never enters that
branch. Anyone who follows this list adds a module that contributes nothing but
attack surface.</p><p><strong><code>jdk.zipfs</code> is still missing.</strong> It appears in neither of the two outputs,
because it is not the target of any call: Jetty requests a file system for a<code>jar:</code> URI, and which provider serves it is decided at run time via<code>ServiceLoader</code>. Nothing of that is in the compiled code.</p><div class="pull-quote"><p>So the exhaustive variant produces a list that is<strong>too large and too small
at the same time</strong>. That is exactly what makes it dangerous: it looks complete.</p></div><h3 id="how-the-absence-shows-itself">How the absence shows itself</h3><p>An image without<code>java.net.http</code>, started against the same libraries,
password check invoked directly:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>### Image WITHOUT java.net.http (15 modules)
password123 -&gt; acceptable=false
correct-horse-battery-staple-42 -&gt; java.lang.NoClassDefFoundError:
java/net/http/HttpTimeoutException
### Image WITH java.net.http (the shipped one, 16 modules)
password123 -&gt; acceptable=false
correct-horse-battery-staple-42 -&gt; acceptable=true</code></pre></div><p>Three observations, each more unpleasant than the last.</p><p><strong>The first line is the same in both cases.</strong> A weak password is
rejected, with and without the module — the local blocklist runs before the
network query and needs no HTTP. Anyone who tests the check with an obviously
bad password sees<strong>no</strong> problem.</p><p><strong>The error does not name the class you expect.</strong> Not<code>HttpClient</code>
but<code>HttpTimeoutException</code> — the class the loading process stumbles over
first. Anyone who searches for the module name will not find it in the error text.</p><p><strong>The timing is the worst imaginable.</strong> The service starts, both
routes respond with 200, login works. The release is declared good.
It breaks the first time someone sets a password — on a fresh
installation that is the setup screen, that is, the very first action of the
very first user.</p><h3 id="what-follows-from-this">What follows from this</h3><p><strong>A startup attempt is not a proof of completeness.</strong> It demonstrates that the
modules suffice for startup, and it says nothing about anything loaded
later.</p><p><strong>The module list needs a test that uses the application</strong> — not
starts it: uses it. On the reference server, that was setting a password. What
such a run does not touch, nobody checks.</p><p><strong>The list belongs documented, not derived.</strong> In the build script it
therefore appears not as the output of a tool but as text with a failure signature:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">sh</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="c1"># What jdeps cannot see. Every entry comes from an actual run, not from</span></span></span><span class="line"><span class="cl"><span class="c1"># an analysis:</span></span></span><span class="line"><span class="cl"><span class="c1">#</span></span></span><span class="line"><span class="cl"><span class="c1"># jdk.zipfs ProviderNotFoundException at startup. Jetty opens</span></span></span><span class="line"><span class="cl"><span class="c1"># archives as file systems.</span></span></span><span class="line"><span class="cl"><span class="c1"># jdk.unsupported NoClassDefFoundError: sun/misc/Unsafe. EclipseStore.</span></span></span><span class="line"><span class="cl"><span class="c1"># java.net.http No error. The image starts, serves pages, and logs</span></span></span><span class="line"><span class="cl"><span class="c1"># users in; the HIBP check only breaks when someone</span></span></span><span class="line"><span class="cl"><span class="c1"># sets a password.</span></span></span><span class="line"><span class="cl"><span class="nv">EXTRA</span><span class="o">=</span><span class="s2">"jdk.zipfs,jdk.unsupported,java.net.http"</span></span></span></code></pre></div></div><p>A comment that names a failure signature is worth more than a list that is
correct. The list goes stale with the next dependency; the failure signature
tells the next person what to look for.</p><h3 id="the-finding-that-holds-the-series-together">The finding that holds the series together</h3><p>It is the third time, and it is the same shape every time.</p><p>In<strong>Part 3</strong> it was the class path: what the compiler sees is not enough for
the annotation scan. In<strong>Part 4</strong> it was the provider directory: what the
compiler sees is not enough for the merge. In<strong>Part 6</strong> it is the
module list.</p><div class="pull-quote"><p><strong>Decisions made at build time about run time have to go beyond what the
compiler sees.</strong> Java loads at run time through reflection, through<code>ServiceLoader</code>, through names in text files. Every tool that predicts run
time from compiled code has its limit exactly here — and none of them tells
you where it lies.</p></div><h2 id="producing-the-image">Producing the image</h2><p>The module list is settled. The invocation that turns it into an image is
short — and every one of its switches has a reason you should know before
adopting it.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">jlink --add-modules<span class="s2">"</span><span class="nv">$MODULES</span><span class="s2">"</span><span class="se">\</span></span></span><span class="line"><span class="cl"> --output<span class="s2">"</span><span class="nv">$DIST</span><span class="s2">/runtime"</span><span class="se">\</span></span></span><span class="line"><span class="cl"> --strip-java-debug-attributes<span class="se">\</span></span></span><span class="line"><span class="cl"> --no-header-files<span class="se">\</span></span></span><span class="line"><span class="cl"> --no-man-pages<span class="se">\</span></span></span><span class="line"><span class="cl"> --compress zip-6</span></span></code></pre></div></div><h3 id="the-switches">The switches</h3><p><strong><code>--strip-java-debug-attributes</code></strong> removes line numbers and local
variable names from the class files of the platform. This affects the
platform classes exclusively; stack traces of the<strong>application</strong> keep their
line numbers, because the application archives in<code>lib/</code> remain untouched. What
you lose are line numbers in JDK-internal frames — bearable when debugging.</p><p><strong><code>--no-header-files</code></strong> and<strong><code>--no-man-pages</code></strong> remove the C header files for
JNI and the manual pages. Nobody needs either on a server.</p><p><strong><code>--compress zip-6</code></strong> compresses the module store. Chapter 9 measures what it
costs and what it brings; the short version is 33 MB less image at
identical startup time.</p><h3 id="-the-switch-that-fails-on-a-lean-debian">⚠️ The switch that fails on a lean Debian</h3><p>The documentation and most tutorials name<code>--strip-debug</code>. On the
reference server it ends like this:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>Error: java.io.IOException: Cannot run program "objcopy":
Exec failed, error: 2 (No such file or directory)</code></pre></div><p><code>--strip-debug</code> invokes an<strong>external program</strong> —<code>objcopy</code> from the GNU
binutils — to remove the symbol tables from the native libraries. On
a development machine it is present, on a minimal
Debian installation it is not.</p><p>Two ways out:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt-get install binutils<span class="c1"># about 20 MB of tooling on the server</span></span></span></code></pre></div></div><p>or the switch that manages without an external program:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">--strip-java-debug-attributes<span class="c1"># does the same inside the JVM</span></span></span></code></pre></div></div><p>On a server that in the end is not supposed to carry any Java at all, it would
be absurd to install binutils so that<code>jlink</code> can run. That is why the build
script contains the second variant.</p><h3 id="where-the-build-takes-place">Where the build takes place</h3><p>Here lies the most uncomfortable property of the whole approach.</p><p><strong>A jlink image is platform-bound.</strong> An image for Linux x86-64 is produced
on Linux x86-64. Anyone who develops on a Mac with Apple silicon — as in
this series — cannot build it on their machine.</p><p>The documented way out is called cross-linking: download a second, complete JDK
for the<strong>target platform</strong> and point<code>jlink</code> via<code>--module-path</code> at its<code>jmods</code> directory. That works and is the right way as soon as a build
machine is involved.</p><p>On the reference server it fails over a detail:<strong>the Temurin build installed
by SDKMAN ships no<code>jmods</code> directory.</strong> Cross-linking would
therefore require a second, complete download — for a platform on
which the build could run anyway.</p><p>So the server builds its image itself. This is possible thanks to<strong>JEP 493</strong>
(<em>Linking Run-Time Images without JMODs</em>, introduced with Java 24):<code>jlink</code>
works from the installed runtime image and no longer needs the<code>jmods</code> files. Without this mechanism, the path on this
server would be blocked.</p><div class="pull-quote"><p><strong>This is a compromise, and let it be named as one.</strong> Part 1, Step 5
argued explicitly for separating build and runtime environments. A
build step that runs on the target machine violates that separation. With a
Linux build machine it would belong there — and with a CI run under Linux it
belongs there anyway. What is shown here is the way for the case
where that machine does not exist.</p></div><h3 id="the-script">The script</h3><p>The whole process lives in<code>tools/jlink-runtime.sh</code>, invoked with the
unpacked distribution directory:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">tools/jlink-runtime.sh /opt/vaadinapp/releases/2026-09-12-jlink</span></span></code></pre></div></div><p>It determines the modules with<code>jdeps</code>, adds the three from Chapter 5, invokes<code>jlink</code>, and reports at the end what was produced:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>jlink-runtime: jdeps found 7, adding 3 it cannot see
jlink-runtime: 65M, 16 modules</code></pre></div><p>That line is worded that way on purpose. Whoever reads it sees immediately that
three modules do not come from the analysis — and knows where to look if the
image later turns out to be missing something.</p><h2 id="merging-image-and-distribution">Merging image and distribution</h2><p>The image exists, the distribution from Part 4 does too. Together they form a
release that expects nothing more from the machine. Two changes are needed
for that — one to the start script, one to the assembly.</p><h3 id="the-shape-of-the-release">The shape of the release</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>2026-09-12-jlink/
├── VERSION
├── bin/start.sh
├── app/vaadinapp.jar 16 KB
├── lib/ 127 archives, 29 MB
├── runtime/ 16 modules, 65 MB ← new
└── sbom.json</code></pre></div><p><code>runtime/</code> joins<code>app/</code> and<code>lib/</code>, it does not replace them. Everything Parts 4
and 5 said about this shape holds unchanged: same unit,
same symlink, same rollback.</p><h3 id="the-change-to-the-start-script">The change to the start script</h3><p>Part 4 introduced the start script with the sentence that the release itself
says how it wants to be started. That is exactly where the decision about the
JVM belongs:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">sh</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="k">if</span><span class="o">[</span> -x<span class="s2">"</span><span class="nv">$APP_HOME</span><span class="s2">/runtime/bin/java"</span><span class="o">]</span><span class="p">;</span><span class="k">then</span></span></span><span class="line"><span class="cl"><span class="nv">JAVA_BIN</span><span class="o">=</span><span class="s2">"</span><span class="nv">$APP_HOME</span><span class="s2">/runtime/bin/java"</span></span></span><span class="line"><span class="cl"><span class="k">else</span></span></span><span class="line"><span class="cl"><span class="nv">JAVA_BIN</span><span class="o">=</span><span class="si">${</span><span class="nv">JAVA_HOME</span><span class="p">:+</span><span class="nv">$JAVA_HOME</span><span class="p">/bin/java</span><span class="si">}</span></span></span><span class="line"><span class="cl"><span class="nv">JAVA_BIN</span><span class="o">=</span><span class="si">${</span><span class="nv">JAVA_BIN</span><span class="k">:-</span><span class="nv">java</span><span class="si">}</span></span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div></div><p>Four lines, and they accomplish more than they appear to.</p><p><strong>A release with its own runtime uses it.</strong> Without configuration, without an
environment variable, without the unit knowing anything about it.</p><p><strong>A release without its own runtime behaves as before.</strong><code>JAVA_HOME</code>, otherwise<code>java</code> from the path — that is literally the version from Part 4.</p><p><strong>And both sit side by side in the same<code>releases/</code> directory.</strong> The
switch between them is the same<code>ln -sfn</code> as any other. That is the
reason the changeover was possible without downtime and the rollback stays
open.</p><p>The proof of that is short and unambiguous: the trial run on the server executed with<strong><code>JAVA_HOME</code> unset</strong> and found its JVM anyway.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>ExecStart=/opt/vaadinapp/current/bin/start.sh</code></pre></div><p>This line was in the unit before the changeover and remains in it, unchanged,
afterwards.</p><h3 id="the-change-to-the-assembly">The change to the assembly</h3><p>The assembly descriptor from Part 4 gets one more<code>fileSet</code>:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">xml</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;fileSet&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;directory&gt;</span>${project.build.directory}/jlink-runtime<span class="nt">&lt;/directory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;outputDirectory&gt;</span>runtime<span class="nt">&lt;/outputDirectory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;fileMode&gt;</span>0644<span class="nt">&lt;/fileMode&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;directoryMode&gt;</span>0755<span class="nt">&lt;/directoryMode&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/fileSet&gt;</span></span></span></code></pre></div></div><p>If an image sits under<code>target/jlink-runtime</code>, it goes into the distribution.
If none is there, the assembly plugin skips the directory
without comment, and the same thin distribution as in Part 4 is produced.</p><p><strong>The Maven build deliberately does not produce the image itself.</strong> If it did,
a development machine would produce an image for that machine, and it would go
into an archive destined for a Linux server — a mistake that would only surface
on the server, and there with a less than helpful<code>Exec format error</code>.</p><p>Instead, the division of labor is:</p><table><thead><tr><th>who</th><th>what</th></tr></thead><tbody><tr><td>Maven</td><td>builds the application, packs<code>app/</code>,<code>lib/</code>,<code>bin/</code>,<code>sbom.json</code></td></tr><tr><td><code>tools/jlink-runtime.sh</code></td><td>builds the image — where it is meant to run</td></tr><tr><td>Assembly</td><td>picks the image up,<strong>if</strong> it is there</td></tr></tbody></table><p>On a Linux build machine all three steps run back to back in the same
pass, and the finished<code>.tar.gz</code> already carries the runtime. Without such a
machine, the middle step runs on the target machine — Chapter 8 shows how.</p><h3 id="what-happens-to-the-sbom">What happens to the SBOM</h3><p><code>sbom.json</code> still describes the application: 129 Java components, 118
JavaScript components.<strong>The Java runtime is not in it.</strong></p><p>That is a gap, and it must be named. Whoever ships the runtime ships
software that until now someone else was responsible for — and the bill of
materials says nothing about it. An image of 16 modules from Temurin 26.0.2.1 is a
component with a version and a provenance, and it belongs on the list.</p><p>The<code>cyclonedx-maven-plugin</code> does not pick it up, because it does not exist at
the build time of the Maven run. It can be added by hand, and what that
looks like belongs in its own context — the two articles on SBOMs and
delivery formats take it up.</p><p>For this part, the finding stands:<strong>the release becomes more complete, its
bill of materials does not.</strong></p><h2 id="getting-it-onto-the-server">Getting it onto the server</h2><p>The procedure is the one from Part 5, with one step inserted.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1 Build and upload — unchanged from Part 5</span></span></span><span class="line"><span class="cl">mvn -Pproduction,thin clean package</span></span><span class="line"><span class="cl">scp embedded-jetty/target/vaadinapp-00.01.00-thin.tar.gz sven@server:/tmp/</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 2 Unpack next to the existing releases</span></span></span><span class="line"><span class="cl"><span class="nv">REL</span><span class="o">=</span>/opt/vaadinapp/releases/2026-09-12-jlink</span></span><span class="line"><span class="cl">sudo mkdir -p<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span></span></span><span class="line"><span class="cl">sudo tar xzf /tmp/vaadinapp-00.01.00-thin.tar.gz --strip-components<span class="o">=</span><span class="m">1</span> -C<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 3 NEW: add the runtime</span></span></span><span class="line"><span class="cl">sudo<span class="nv">JAVA_HOME</span><span class="o">=</span>/var/lib/vaadinapp/.sdkman/candidates/java/current<span class="se">\</span></span></span><span class="line"><span class="cl"> sh /opt/vaadinapp/tools/jlink-runtime.sh<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 4 Ownership — as for every release</span></span></span><span class="line"><span class="cl">sudo chown -R root:vaadinapp<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span></span></span><span class="line"><span class="cl">sudo chmod -R g-w,o-rwx<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 5 Switch over and restart — unchanged from Part 5</span></span></span><span class="line"><span class="cl">sudo ln -sfn<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span> /opt/vaadinapp/current</span></span><span class="line"><span class="cl">sudo systemctl restart vaadinapp</span></span></code></pre></div></div><p>Step 3 is the only addition. Steps 1, 2, 4, and 5 are literally the ones
from Part 5, Chapter 4.</p><h3 id="the-result-1">The result</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>current -&gt; releases/2026-09-12-jlink
JVM /opt/vaadinapp/current/runtime/bin/java
Service active (running)
HTTP 200
Hardening 1.1 OK</code></pre></div><p><strong>The hardening score is unchanged.</strong> That is not self-evident: the
runtime now sits below<code>/opt/vaadinapp</code>, that is, in a
directory the service reads. It is owned by<code>root:vaadinapp</code> and is not
writable for the group — the same rule that Pitfall 12 from Part 1 enforced for
the SDKMAN directory, applied here to the release. The service
can execute its JVM and cannot replace it.</p><p><code>ReadWritePaths</code> stays at<code>/var/lib/vaadinapp/work</code> and<code>…/logs</code>. The release
is immutable for the service, runtime included.</p><h3 id="what-the-changeover-cost">What the changeover cost</h3><p>Nothing beyond an ordinary release switch. The service was
unreachable for the duration of one restart; the number is in Chapter 9.
Caddy held the connections during that time; the public response stayed
at 200, because access runs through the reverse proxy and not directly against
port 8080.</p><h3 id="-the-jdk-cannot-go-yet">⚠️ The JDK cannot go yet</h3><p>The obvious next move would be to remove the SDKMAN JDK. It would be
premature.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>/opt/vaadinapp/releases/
├── 2026-09-09-thin/ needs the installed JDK
├── 2026-09-09-fat/ needs the installed JDK
└── 2026-09-12-jlink/ brings its own runtime</code></pre></div><p>Only the newest release is self-contained. The two predecessors fall back to
the installed JDK via<code>JAVA_HOME</code> — and precisely they are the target of a
rollback. Removing the JDK would mean giving up the rollback, and with it
the property that Part 5 singled out as the most important.</p><div class="pull-quote"><p><strong>It becomes a server without an installed Java only when no release
needs one anymore.</strong> The gain lies not in building the image but in the clearing
away afterwards — and that moment comes not with the first jlink release, but
with the last runtime-less release that you still want to keep
available for a rollback.</p></div><p>For the reference server that means: the 309 MB stay put for now. Anyone who
wants to be rid of them keeps the predecessors as<code>.tar.gz</code> in an archive
instead of as unpacked releases — then a rollback is one extra unpack, and the
JDK can go.</p><h3 id="the-build-step-on-the-target-machine-once-more">The build step on the target machine, once more</h3><p>Step 3 invokes a compiler component on a production server. That is
the spot where this chapter is unclean, and let it be named clearly:</p><ul><li>A<strong>full JDK</strong> must sit on the server so that<code>jlink</code> and<code>jdeps</code> are
available — the very JDK you wanted to get rid of.</li><li>The step runs as<code>root</code>.</li><li>It is not repeatable in the sense that two runs on two servers would
produce the same image; they produce two images from two JDK states.</li></ul><p><strong>The clean way is a build machine running Linux.</strong> There, steps 1
and 3 run together, the<code>.tar.gz</code> already carries the runtime, and what remains
on the server is unpacking and switching over. The server then needs no JDK — and
step 3 disappears there entirely.</p><p>For this series that machine does not exist, and rather than pretending it
does, what stands here is the way that works without it.</p><h2 id="measuring-what-a-runtime-of-your-own-costs">Measuring: what a runtime of your own costs</h2><p>Four variants, three runs each, all on the same server, all with the same
application and the same unit.</p><table><thead><tr><th>Variant</th><th>Image</th><th>Downtime</th><th>Uptime</th><th><code>MemoryCurrent</code></th><th>RSS</th></tr></thead><tbody><tr><td>Thin distribution, full JDK</td><td>309 MB</td><td>4,653 ms</td><td><strong>3,574 ms</strong></td><td><strong>358 MiB</strong></td><td>382 MiB</td></tr><tr><td><strong>jlink,<code>zip-6</code></strong><em>(shipped)</em></td><td><strong>65 MB</strong></td><td>4,886 ms</td><td>3,866 ms</td><td>370 MiB</td><td>401 MiB</td></tr><tr><td>jlink, no compression</td><td>98 MB</td><td>4,763 ms</td><td>3,852 ms</td><td>373 MiB</td><td>393 MiB</td></tr><tr><td>jlink,<code>zip-6</code> + CDS</td><td>93 MB</td><td>4,895 ms</td><td>3,899 ms</td><td><strong>359 MiB</strong></td><td>392 MiB</td></tr></tbody></table><p><em>Downtime</em> is the span during which the service does not respond on port 8080.<em>Uptime</em> is reported by the application itself, measured from process start to
Jetty&rsquo;s operational readiness.</p><h3 id="size-1">Size</h3><p><strong>79% less</strong> — 65 instead of 309 MB. That is the number that convinces, and
Chapter 2 has already said why it weighs the least in this setup: the
server has the space.</p><p>More interesting is the ratio inside the release. At 65 MB, the runtime is<strong>more than twice the size of the application</strong> (29 MB of<code>lib/</code> plus 16 KB of<code>app/</code>). Whoever ships a release that carries its own runtime ships
two-thirds Java.</p><h3 id="startup-takes-longer">Startup takes longer</h3><p>And that was not expected.</p><p><strong>Around 290 ms</strong> more uptime, 3,866 versus 3,574 ms. Reproducible across all
runs, clearly outside the noise. Two explanations suggested themselves, both
were tested,<strong>both are refuted</strong>:</p><p><strong>The compression.</strong><code>zip-6</code> has to be decompressed on loading — that
plausibly costs time. Measured: the uncompressed image (98 MB) lowers the
downtime by 123 ms and leaves<strong>the uptime unchanged</strong> at 3,852 ms. So the
compression costs nothing in application startup time.</p><p><strong>The missing class-data archive.</strong> A full Temurin ships four prepared
CDS archives; a jlink image does not. That is more than a guess — it
is right there in the version output:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>full JDK: ... (build 26.0.2.1+1, mixed mode, sharing)
jlink image: ... (build 26.0.2.1+1, mixed mode)</code></pre></div><p>The<code>sharing</code> is missing. Measured with<code>--generate-cds-archive</code>: the uptime
even rises slightly, to 3,899 ms.<strong>That is not it either.</strong></p><div class="pull-quote"><p><strong>The cause remains open.</strong> That is an unsatisfying sentence at the end of a
measurement chapter, and it is preferable to the alternative: writing down
one of the two refuted explanations anyway because it sounds plausible.
Whoever knows the cause, please say so.</p></div><p>The number still deserves context: 290 ms on 3.5 seconds is 8%, and it
is incurred by a service that starts once per release. For this setup
it is irrelevant. For an application that starts per invocation it would be the
opposite — and there the tool of choice would not be jlink anyway, but a
native image.</p><h3 id="memory">Memory</h3><p>The jlink image needs<strong>12 MiB more</strong>, 370 versus 358 MiB. Same application,
same settings, fewer modules — and more memory.</p><p>Here the CDS explanation that failed for startup time does apply: with<code>--generate-cds-archive</code> the value drops to 359 MiB, practically to the level
of the full JDK. A class-data archive allows the class state to be mapped in
as a memory-mapped file instead of being placed on the heap — exactly
what the image without an archive lacks.</p><p>That makes CDS, in this setup, a<strong>memory tool, not a
speed tool</strong>:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>+28 MB image → −11 MiB memory → ±0 ms startup time</code></pre></div><p>Whether that is worth it depends on what is scarcer. On a server with 4 GB of RAM
and plenty of disk: yes. On the reference server, which is short of neither
memory nor disk: not necessary — and that is why the shipped image is the one
without CDS.</p><h3 id="what-did-not-change">What did not change</h3><table><thead><tr><th/><th>full JDK</th><th>jlink</th></tr></thead><tbody><tr><td>Threads in operation</td><td>31</td><td>31</td></tr><tr><td>HTTP response</td><td>200</td><td>200</td></tr><tr><td>Hardening score</td><td>1.1</td><td>1.1</td></tr><tr><td>Lines in the systemd unit</td><td>unchanged</td><td>unchanged</td></tr></tbody></table><p>The application notices nothing of running on a different JVM. That is
the real finding of this chapter:<strong>an image of 16 modules behaves
like a JDK of 68</strong> — except for 290 ms that nobody can explain and 12 MiB
that CDS brings back.</p><h2 id="what-changes-about-the-release-model">What changes about the release model</h2><p>Moving the runtime into the release shifts a
responsibility. That has two sides, and the unpleasant one comes first.</p><h3 id="a-java-update-is-now-a-release">A Java update is now a release</h3><p>Before:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sdk install java 26.0.3-tem</span></span><span class="line"><span class="cl">sdk default java 26.0.3-tem</span></span><span class="line"><span class="cl">chown -R root:vaadinapp /var/lib/vaadinapp/.sdkman</span></span><span class="line"><span class="cl">systemctl restart vaadinapp</span></span></code></pre></div></div><p>Four lines, no build, no artifact, a few minutes.</p><p>After: build a new image, pack it into a new release, ship it, repoint the
symlink. The procedure from Chapter 8, in full.</p><p><strong>That is more effort, and it should not be talked down.</strong> Anyone who applies a
monthly JDK security update will from now on run it through the release pipeline.
Without build automation that carries it, this quickly becomes an update that
does not happen — and an outdated Java in the release is worse than a
current one on the machine.</p><p>One qualification puts the objection into perspective for this server:<strong>the
automatic updating from Part 1, Step 21 never covered the JDK anyway.</strong> It
maintains Debian packages; the JDK came via SDKMAN. So the step was already
manual before. What changes is not that it becomes manual, but that it becomes<strong>visible</strong>: as a release with a date that can be kept available or taken back,
instead of a symlink that someone repointed at some point.</p><h3 id="in-return-the-java-version-travels-with-the-rollback">In return, the Java version travels with the rollback</h3><p>That is the other side, and in a series that dedicated Part 5 to the rollback
it is the more important one.</p><p>Until now a rollback was incomplete: it took back the application and left
the runtime standing. Anyone who observes a problem after a JDK update and
goes back to the previous release goes back to the previous<strong>application</strong> — on
the new Java. The problem stays, and the cause is now harder to
find, because the one change that was just taken back was not the one
that mattered.</p><p>With its own runtime the rollback is complete.<code>ln -sfn</code> to the previous
release takes back application and JVM together.<strong>The state that ran can be
restored as a whole</strong> — and not just two-thirds of it.</p><h3 id="the-attack-surface-provable">The attack surface, provable</h3><p>52 modules no longer sit on the server. What that is worth shows when
a vulnerability report comes in.</p><p>With a full JDK, the question &ldquo;Does this affect us?&rdquo; is a judgment call: the
module is there, maybe it is not loaded, you cannot be sure. With a
jlink image it is a query:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">/opt/vaadinapp/current/runtime/bin/java --list-modules<span class="p">|</span> grep<span class="s1">'^jdk.scripting'</span></span></span><span class="line"><span class="cl"><span class="c1"># (no output)</span></span></span></code></pre></div></div><p><strong>No match means: not included.</strong> That is not an argument you have to
make, but one you can prove — and for a VEX document that justifies the
status<code>not_affected</code>, the difference is exactly the one between a
claim and evidence.</p><p>The same holds for the case Chapter 2 described: anyone who can execute code
on the server finds no compiler there, no scripting engine, and
no tools for attaching to other JVM processes. That prevents no
break-in. It shortens what is possible afterwards.</p><h3 id="the-gap-that-remains">The gap that remains</h3><p>Two things do not get better through this approach, and they belong
here, because otherwise the chapter would look too good.</p><p><strong>The bill of materials does not know the runtime.</strong> Chapter 7 named it:<code>sbom.json</code>
describes 129 Java and 118 JavaScript components; the 16 modules of the image
are not in it. Of all things, the component for which responsibility
was just taken is missing from the document that is supposed to prove
responsibility.</p><p><strong>The module list is a maintained file.</strong> It comes from an analysis plus
three entries from failure signatures (Chapter 5). If a dependency arrives that
needs another module, nobody notices automatically — in the fortunate case
at startup, in the unfortunate one with the first user who invokes the affected
function.</p><p>Whoever adopts jlink takes on both as an ongoing task. That is the price
for the runtime becoming part of the release, and it is only
appropriate once the gain is actually needed.</p><h2 id="the-comparison-across-the-series--and-where-the-application-ends">The comparison across the series — and where the application ends</h2><p>Six parts, five delivery formats, one server. What each format expects from the
target system — and what it brings along.</p><h3 id="the-overview">The overview</h3><table><thead><tr><th>Part</th><th>Form</th><th>Release on the server</th><th>required there</th></tr></thead><tbody><tr><td>1–2</td><td>WAR to external Jetty</td><td>21 MB</td><td>JDK (309 MB)<strong>and</strong> Jetty (44 MB distribution)</td></tr><tr><td>3</td><td>embedded Jetty, unpacked</td><td>29 MB</td><td>JDK</td></tr><tr><td>4</td><td>thin distribution</td><td>29 MB</td><td>JDK</td></tr><tr><td>4</td><td>fat JAR</td><td>28 MB</td><td>JDK</td></tr><tr><td>6</td><td>thin distribution + own runtime</td><td>94 MB</td><td><strong>nothing</strong></td></tr></tbody></table><p>All five variants sit simultaneously in<code>/opt/vaadinapp/releases/</code> and were
measured in a single pass — that is why the numbers are comparable. The archive
sizes from Part 5 (26.68 MB for the thin distribution, 25.81 MB for the fat JAR)
come from an older state of the code and are not.</p><p>The right-hand column is the thread that runs through the series, and read from
top to bottom it is a single movement: what the machine provided at first, the
release provides at the end.</p><p>The column next to it shows what that costs — and across four
parts the answer is: almost nothing. The release stays between 21 and 29 MB while
the prerequisites on the server shrink from two third-party components to one.
The Jetty from Part 1 moves into the application in Part 3 and shows up there as
the 8 MB difference between 21 and 29 MB — a good deal against a
44 MB distribution that someone has to install and maintain.</p><p>Only the last step costs:<strong>from 29 to 94 MB.</strong> The difference is the
runtime, and it is more than twice the size of the application
that it runs.</p><h3 id="which-form-for-what">Which form for what</h3><p><strong>WAR to an external Jetty</strong> — when operations provides an application server
and several applications run on it. Then Jetty is shared
infrastructure, and shipping it per application would be wasteful. The price
is depending on two states that someone else maintains; Part 2 showed
how tight the coupling between Vaadin version and servlet version
actually is.</p><p><strong>Fat JAR</strong> — the default for the normal case. One archive, one checksum,
no directory of 128 files. Part 5 measured that it starts neither faster
nor slower and needs around 48 MiB more memory; that is tolerable.
The price is in Part 4, Chapter 5: the merge is not a copy operation,
and whoever gets it wrong builds an archive that starts and does not work.</p><p><strong>Thin distribution</strong> — when you want to see what is being shipped. 127 archives,
each with a name and a version, each individually replaceable. For debugging and for
everything related to license and vulnerability checking, this is the
more pleasant form.</p><p><strong>Own runtime</strong> — when the Java version is to belong to the release. Three cases
justify the effort: a target system on which no Java can or may be
installed; an operation in which the rollback must be complete; and
an environment in which the attack surface must be provable rather than estimated.</p><p>For everything else, an installed JDK plus fat JAR is the simpler solution,
and in operations, simpler is a value in itself.</p><h3 id="where-does-the-application-end">Where does the application end?</h3><p>The series has been moving this boundary for six parts, and at no point was it
clear where it belongs.</p><p>Part 1 would have answered: the application ends at the WAR. Everything before
it is its business, everything after it is operations. Part 3 pulled Jetty
inside, because the coupling between Vaadin and servlet version was too tight
to run it across a system boundary. Part 6 pulled the JVM inside, because a
rollback that leaves the runtime standing is not a complete rollback.</p><p>What remains is not a technical answer but one about
responsibilities:</p><div class="pull-quote"><p><strong>The application ends where the next decision is made by someone else
— and where that is fine.</strong> Not every shared responsibility is a
problem. It becomes one when two places decide about the same behavior
and neither of them checks the whole.</p></div><p>This series pushed the boundary far, because the reference server is one
that a single person operates. Where one team provides operations and another the
application, it lies elsewhere — and there the WAR from Part 1 would be the right
answer, not the outdated one.</p><h3 id="the-step-that-does-not-come">The step that does not come</h3><p>The container.</p><p>It is the obvious next thought, and it solves the same problem — an
image that brings everything along, on a host that provides nothing. Two things
speak for treating it separately.</p><p><strong>It does not replace the question, it relocates it.</strong> A container image with a
full JDK carries the same 309 MB, just one layer further down. The work from
Chapters 4 and 5 is due there just the same, and it pays off there<strong>more
strongly</strong> than here: an image that is multiplied into every registry, onto
every node, and into every layer profits from 79% less footprint more than a
disk on which 309 MB sit once. Chapter 2 ranked that as this series&rsquo; weakest
argument — in the container it becomes the strongest.</p><p><strong>It brings a second system boundary with it.</strong> Network, file system, process
isolation, registry, signing — each of these is a topic of its own, and the
systemd hardening from Part 1, which this series holds at 1.1, would have to be
answered anew there. Squeezing that into a closing chapter would mean treating
it badly.</p><p>What carries over from this series into a container is therefore not the
result but the method:<strong>a module list that cannot be derived;
a startup attempt that proves nothing; and a release that describes its own
state completely.</strong> Whoever has that can put it into a container image.
Whoever does not puts a problem inside and a layer on top.</p>
]]></content:encoded><category>Java</category><category>Vaadin</category><media:content url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink-hero.png" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink-hero.png"/><enclosure url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink-hero.png" type="image/jpeg" length="0"/></item></channel></rss>