<?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>Thin Distribution on Sven Ruppert</title><link>https://svenruppert.com/tags/thin-distribution/</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/thin-distribution/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/thin-distribution/</link></image><lastBuildDate>Thu, 24 Sep 2026 10:00:00 +0200</lastBuildDate><item><title>Vaadin Deployment on Hetzner — Part 4: Fat JAR and Thin Distribution</title><link>https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-4-fat-jar-and-thin-distribution/</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-4-fat-jar-and-thin-distribution/</guid><description>Building fat JAR and thin distribution from the same Vaadin project: Maven profiles, reproducible packaging, and what the classpath decides at startup.</description><content:encoded>&lt;![CDATA[<p>Fourth part of the series<em>Vaadin – Deployment on Hetzner</em>. The focus is not on the bootstrap but on the packaging: one archive or a directory tree, and what that means for delivery, rollback, and diagnosis. All commands, outputs, and measurements come from an actual run.</p><h2 id="bootstrap-and-packaging-are-two-decisions">Bootstrap and packaging are two decisions</h2><p>At the end of Part 3, the application brings its own server. There is no<code>/opt/jetty</code> anymore, no<code>start.d/*.ini</code>, no module mechanism — just a<code>main()</code> method that assembles server, connector, and context. What sits on the
reference server 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/
├── app.jar 8,908 Bytes
├── lib/ 127 archives, 29.74 MB
├── logs/
└── releases/</code></pre></div><p>It is started with an explicit classpath:</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">java -cp<span class="s1">'/opt/vaadinapp/app.jar:/opt/vaadinapp/lib/*'</span><span class="se">\</span></span></span><span class="line"><span class="cl"> com.svenruppert.flow.Application</span></span></code></pre></div></div><p>That is a delivery form. But it is not one that was ever decided on. It is what
remained after Part 3 had answered a different question.</p><h3 id="the-boundary-runs-differently-here">The boundary runs differently here</h3><p>Every part so far has moved a boundary. Part 1 turned the application into a
service, Part 2 made it publicly reachable, Part 3 pulled the server behind the
dashed line. This part moves no boundary. It changes<strong>not a single line of
Java</strong>.</p><p>That is not modesty but the point of this part: the bootstrap and the packaging
are two independent decisions, and they are regularly confused with each other.</p><table><thead><tr><th>Question</th><th>Decision</th><th>answered in</th></tr></thead><tbody><tr><td>Who starts the server?</td><td>Container with WAR or its own<code>main()</code></td><td>Part 3</td></tr><tr><td>How does what is started get onto the machine?</td><td>one archive or a directory tree</td><td>here</td></tr></tbody></table><p>Part 3 left the second question open — visible in the fact that the delivery
there knows neither a start script nor a release structure. The classpath sits
in the systemd unit, the directory<code>/opt/vaadinapp</code> contains exactly one
version, and a change overwrites it.</p><h3 id="two-answers-to-choose-from">Two answers to choose from</h3><figure><img src="/images/2026/09/vaadin-deployment-on-hetzner-part-4-fat-jar-and-thin-distribution-teil04-verpackungen.png" alt="Two packagings of the same application" loading="lazy" decoding="async"/><p><em>Figure 1: The same classes, the same dependencies — two delivery forms.</em></p><p><strong>The Fat JAR</strong> puts everything into a single archive. 128 files become one.
Startup is<code>java -jar</code>, delivery is an<code>scp</code>, and what lies inside the archive
can no longer be seen from the outside.</p><p><strong>The Thin Distribution</strong> keeps the separation and organizes it: the start
script in<code>bin/</code>, the application archive in<code>app/</code>, the dependencies in<code>lib/</code>. Startup is a script, delivery is a directory, and every library remains
visible as its own file.</p><p>Both forms are old, both are in use, and neither is the more modern one.
Whoever pits them against each other is really asking a different question:</p><div class="pull-quote"><p><strong>Should the delivery show its components or hide them?</strong></p></div><h3 id="why-these-are-two-profiles-and-not-two-modules">Why these are two profiles and not two modules</h3><p>Part 3 introduced a separate Maven module for each delivery form —<code>war-jetty</code> and<code>embedded-jetty</code>. The reason was compelling: both forms needed
the same source tree under incompatible assumptions, and exactly that produced
the mistake that carried Jetty into the WAR.</p><p>Here, that conflict does not exist. Fat JAR and Thin Distribution arise from
the same classes, the same dependencies, and the same scopes. They differ
solely in how the result is wrapped up. Two modules that contain no Java code
and differ only in one plugin block would be scaffolding without substance —
the same kind of construction Part 3 criticized in the deleted<code>_shadejar</code>
profile.</p><p>That is why the reactor stays at three modules, and the two packagings are two
profiles of one of them:</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">mvn -Pproduction,thin package<span class="c1"># bin/, app/, lib/, and a .tgz</span></span></span><span class="line"><span class="cl">mvn -Pproduction,fatjar package<span class="c1"># bin/, app/ with one archive inside</span></span></span><span class="line"><span class="cl">mvn package<span class="c1"># just the application archive, for development</span></span></span></code></pre></div></div><h3 id="the-code-state-for-this-part">The code state for this part</h3><p>The repository carries the state this part describes as tag<strong><code>teil-04</code></strong>.
A<code>git diff teil-03 teil-04</code> shows exactly what was added: the two profiles,
the assembly descriptors, and the start script — not a line of Java.</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-04</span></span></code></pre></div></div><p>At state<code>teil-04</code>, there is<strong>no</strong> bill of materials in the release yet. The
project does generate an SBOM, but it stays in the build directory. Why that is
a shortcoming and how it gets fixed is the subject of a separate article; the
measurements in this part refer to the state without it.</p><h3 id="what-this-part-delivers">What this part delivers</h3><p>At the end, both forms stand built side by side: an archive of just under 29 MB
and a directory tree of 130 files, both from the same source code, both with
the same start script. Plus the places where the merging fails silently, and
the reason why a Fat JAR starts at all where Part 3 had measured the opposite.</p><p><strong>Delivery happens in Part 5.</strong> That is where the releases in numbered
directories come in, the<code>current</code> symlink, the rollback — and the measurement
that decides between the two forms. Part 1 had explicitly deferred the symlink
procedure to &ldquo;where it unfolds its purpose with the directory distribution&rdquo;;
that promise is redeemed in the next part.</p><p>The starting point is the question every packaging begins with: what does the
build leave behind that wants to be packaged in the first place?</p><h2 id="what-the-build-leaves-behind">What the build leaves behind</h2><p>Before deciding on packagings, it must be clear what is to be packaged. A<code>mvn -Pproduction package</code> in the reactor leaves behind not a single result but
a set of components of very different sizes and origins.</p><h3 id="the-application-code">The application code</h3><p>The<code>embedded-jetty</code> module produces an archive of<strong>10,686 bytes</strong>. It
contains a single class:<code>Application</code>, the bootstrap from Part 3. Nothing else.</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>$ ls -l embedded-jetty/target/vaadinapp-embedded-jetty-00.01.00.jar
-rw-r--r-- 10686 vaadinapp-embedded-jetty-00.01.00.jar</code></pre></div><p>Ten kilobytes against just under thirty megabytes of dependencies — the ratio
is roughly 1 to 2,800. That number is the reason the question of this part gets
asked at all: if something changes in the application, ten kilobytes change.
Everything else stays as it was.</p><h3 id="vaadin-and-the-production-bundle">Vaadin and the production bundle</h3><p>The application itself — views, security layer, translations — lives in the<code>core</code> module and weighs 2.47 MB. The larger part of it is not Java bytecode
but the<strong>production bundle</strong>: 142 entries under<code>META-INF/VAADIN</code>, left there
by the<code>vaadin-maven-plugin</code>&rsquo;s<code>build-frontend</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>$ unzip -l lib/vaadinapp-core-00.01.00.jar | grep -c META-INF/VAADIN
142</code></pre></div><p>This bundle is only produced under<code>-Pproduction</code>. If it is missing, the
application starts anyway — and serves an empty page. Part 3 therefore built a
check into the bootstrap that aborts startup instead of offering a server that
has nothing to display.</p><h3 id="jetty">Jetty</h3><p>26 archives, 4.1 MB. This is the server Part 3 turned into a dependency:<code>jetty-server</code>,<code>jetty-ee11-webapp</code>, the annotation scanner, the WebSocket
container, and their underpinnings.</p><p>For comparison: the Jetty<strong>installation</strong> Part 3 dismantled occupied 53 MB.
The embedded setup gets by with a thirteenth of that, because it brings only
the libraries and not the module mechanism, startup process, and deployment
scanner.</p><h3 id="the-remaining-dependencies">The remaining dependencies</h3><table><thead><tr><th>Origin</th><th style="text-align: right">Archives</th><th style="text-align: right">Size</th><th style="text-align: right">Share</th></tr></thead><tbody><tr><td>BouncyCastle</td><td style="text-align: right">1</td><td style="text-align: right">8.5 MB</td><td style="text-align: right">30 %</td></tr><tr><td>Vaadin and Push</td><td style="text-align: right">57</td><td style="text-align: right">4.8 MB</td><td style="text-align: right">17 %</td></tr><tr><td>Jetty</td><td style="text-align: right">26</td><td style="text-align: right">4.1 MB</td><td style="text-align: right">15 %</td></tr><tr><td>Jackson, jsoup, and others</td><td style="text-align: right">7</td><td style="text-align: right">3.1 MB</td><td style="text-align: right">11 %</td></tr><tr><td>EclipseStore</td><td style="text-align: right">7</td><td style="text-align: right">3.1 MB</td><td style="text-align: right">11 %</td></tr><tr><td>own modules</td><td style="text-align: right">3</td><td style="text-align: right">2.6 MB</td><td style="text-align: right">9 %</td></tr><tr><td>jCustos</td><td style="text-align: right">9</td><td style="text-align: right">1.0 MB</td><td style="text-align: right">4 %</td></tr><tr><td>Jakarta APIs</td><td style="text-align: right">10</td><td style="text-align: right">0.8 MB</td><td style="text-align: right">3 %</td></tr><tr><td>ASM</td><td style="text-align: right">4</td><td style="text-align: right">0.3 MB</td><td style="text-align: right">1 %</td></tr><tr><td>SLF4J</td><td style="text-align: right">3</td><td style="text-align: right">0.1 MB</td><td style="text-align: right">0 %</td></tr><tr><td><strong>total</strong></td><td style="text-align: right"><strong>127</strong></td><td style="text-align: right"><strong>28.4 MB</strong></td><td style="text-align: right"/></tr></tbody></table><p>The first row deserves a look.<strong>A single archive,<code>bcprov-jdk18on</code>, accounts
for 30 percent of the entire delivery.</strong> BouncyCastle provides Argon2id for the
password hashes; alongside that, the library brings the complete rest of its
cryptography, none of which the application ever calls.</p><p>This is the point where the temptation arises to clean up with<code>minimizeJar</code> —
and the point where that temptation must be resisted. The minimization works
via static reachability; it does not see what gets loaded via<code>ServiceLoader</code>,
reflection, or a provider name in a configuration file. With a cryptography
library whose algorithms are registered in exactly that way, this is no
theoretical risk. The series keeps BouncyCastle in full.</p><h3 id="what-follows-from-this">What follows from this</h3><p>Two observations carry the rest of this part.</p><p><strong>First: the components change at different speeds.</strong> The ten kilobytes of
application code change with every bug fix; the 8.5 MB of BouncyCastle change
when a security advisory forces it. A packaging that treats both the same
transfers everything on every change.</p><p><strong>Second: the components cannot be thrown together arbitrarily.</strong> 127 archives
carry 36 service provider files, five of which share the same name. Today they
sit side by side because every archive has its own<code>META-INF</code>. Whoever puts
everything into one archive must answer this question — otherwise chance
answers it. Chapter 5 shows what happens then.</p><h2 id="the-fat-jar">The Fat JAR</h2><h3 id="the-basic-idea">The basic idea</h3><p>A JAR is a ZIP archive with a manifest. The Java runtime places no condition on
its content other than that class files sit under their package path. Nothing
prevents putting the content of<strong>all</strong> dependencies into the same archive.</p><p>That is exactly what a Fat JAR is: 128 archives are unpacked and their content
written into one shared archive. What results is, to the JVM, a perfectly
ordinary archive — it does not notice that it was assembled from many.</p><p>The appeal is obvious:<strong>one file.</strong> Copying is atomic, there is no classpath
to formulate, no directory that can arrive incomplete during transfer, and no
way to forget a library.</p><h3 id="executable-archive-and-manifest">Executable archive and manifest</h3><p><code>java -jar archiv.jar</code> works if the manifest names a<code>Main-Class</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>Manifest-Version: 1.0
Main-Class: com.svenruppert.flow.Application
Multi-Release: true</code></pre></div><p>The second entry is the one that gets overlooked. Several dependencies ship
version-dependent classes under<code>META-INF/versions/</code>; in the built Fat JAR of
this application there are<strong>1,576 entries</strong>. Without<code>Multi-Release: true</code>,
the JVM skips every one of them and uses the base version — no message, no
error, and possibly different behavior.</p><p>This fits a pattern that runs through this part: packaging mistakes do not
announce themselves at build time.</p><h3 id="building-with-shade">Building with Shade</h3><p>There are three ways to produce a Fat JAR. The series uses<code>maven-shade-plugin</code>, and the other two were ruled out for reasons that belong
to the subject matter.</p><p><strong>Nested archives</strong> — Spring Boot&rsquo;s approach — place the archives as archives
inside the outer archive. That preserves their separation but demands a custom
class loader, because the JVM does not read archives inside archives on its
own. The application would then face the problem from Part 3 again:<code>java.class.path</code> would contain only the outer archive, and Jetty&rsquo;s scanner
would not see the inner ones. Spring Boot solves this with its own class loader<strong>and</strong> its own integration with the frameworks. Without that substructure, the
approach is the worse choice for an application that relies on annotation
scanning.</p><p><strong><code>maven-assembly-plugin</code></strong> could do both, but its prefabricated descriptor<code>jar-with-dependencies</code> does<strong>not</strong> merge<code>META-INF/services</code> — it declares no<code>containerDescriptorHandler</code>. The files then overwrite each other, and what
that wreaks is shown in Chapter 5 on a running example.</p><p>That leaves<a href="https://8g8.eu/dm2rpz">Shade</a>. The configuration sits in a profile of the<code>embedded-jetty</code> module:</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;plugin&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>org.apache.maven.plugins<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>maven-shade-plugin<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;executions&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;execution&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;id&gt;</span>shade-application-jar<span class="nt">&lt;/id&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;phase&gt;</span>package<span class="nt">&lt;/phase&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;goals&gt;&lt;goal&gt;</span>shade<span class="nt">&lt;/goal&gt;&lt;/goals&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;configuration&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;outputFile&gt;</span>${project.build.directory}/vaadinapp-${project.version}-fat.jar<span class="nt">&lt;/outputFile&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;createDependencyReducedPom&gt;</span>false<span class="nt">&lt;/createDependencyReducedPom&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;minimizeJar&gt;</span>false<span class="nt">&lt;/minimizeJar&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;transformers&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;transformer</span><span class="na">implementation=</span><span class="s">"…ManifestResourceTransformer"</span><span class="nt">&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;mainClass&gt;</span>com.svenruppert.flow.Application<span class="nt">&lt;/mainClass&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;manifestEntries&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;Multi-Release&gt;</span>true<span class="nt">&lt;/Multi-Release&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/manifestEntries&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/transformer&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;transformer</span><span class="na">implementation=</span><span class="s">"…ServicesResourceTransformer"</span><span class="nt">/&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/transformers&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;filters&gt;</span>…<span class="nt">&lt;/filters&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/configuration&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/execution&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/executions&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/plugin&gt;</span></span></span></code></pre></div></div><p>Two settings deserve a justification.</p><p><strong><code>outputFile</code></strong> places the merged archive<strong>next to</strong> the ordinary one instead
of replacing the module&rsquo;s main artifact. Without this line, only the Fat JAR
would remain after the build — and with it, nothing left to compare it against.
Both forms are meant to sit side by side in<code>target/</code>.</p><p><strong><code>minimizeJar=false</code></strong>, for the reason from Chapter 2: minimization removes
what is not statically reachable and thereby misses everything loaded via<code>ServiceLoader</code> or reflection. With 30 percent cryptography in the archive,
this is not a setting to economize on.</p><p>The invocation:</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">mvn -Pproduction,fatjar package</span></span></code></pre></div></div><p>The result is a file of<strong>28,890,183 bytes</strong> with 17,904 entries, started with<code>java -jar</code>, HTTP 200 on<code>/</code> and<code>/login</code>.</p><p>It is not delivered bare but in a release of the same shape as the Thin
Distribution — the justification for that is in Chapter 6. Which first raises a
question that Part 3 had answered differently.</p><h2 id="why-the-fat-jar-works-where-java--jar-failed">Why the Fat JAR works where<code>java -jar</code> failed</h2><p>Part 3 carries a chapter titled &ldquo;Why there is no<code>java -jar</code>&rdquo;.
It justifies this with a measurement:</p><table><thead><tr><th>Startup</th><th>Jetty&rsquo;s timestamp</th></tr></thead><tbody><tr><td><code>java -jar app.jar</code></td><td>139 ms</td></tr><tr><td><code>java -cp 'app.jar:lib/*'</code></td><td>1,523 ms</td></tr></tbody></table><p>The reasoning there: with<code>java -jar</code>,<code>java.class.path</code> contains a single
entry — the application archive. Exactly this property is what Jetty&rsquo;s<code>MetaInfConfiguration</code> reads to determine which archives the annotation scanner
opens. No scan, no<code>RouteRegistryInitializer</code>, no<code>StaticFileHandler</code>. The
server responds, and every request fails.</p><p>The Fat JAR is started with exactly this<code>java -jar</code>. And it runs.</p><h3 id="the-resolution">The resolution</h3><p>It is the same mechanism, viewed from the other side.</p><p>With<code>java -jar</code>,<strong>one</strong> archive sits on the classpath, and exactly this one
is searched. The only question is what lies inside it.</p><table><thead><tr><th/><th>Content of the one archive</th><th>Result of the scan</th></tr></thead><tbody><tr><td>Thin Distribution with<code>java -jar</code></td><td><code>Application.class</code></td><td>nothing</td></tr><tr><td>Fat JAR with<code>java -jar</code></td><td>all 128 archives</td><td>everything</td></tr></tbody></table><p>In the Thin Distribution, the<code>ServletContainerInitializer</code> Vaadin needs sits
in<code>flow-server</code>. That file is never opened, because it is inside one of the
127 archives that are not on the classpath. In the Fat JAR, it sits in the same
archive as everything else.</p><div class="pull-quote"><p><strong>The Fat JAR works not despite but because of the property that breaks the
Thin Distribution. One archive on the classpath is enough exactly when it is
complete.</strong></p></div><p>Whoever has understood this also understands why Spring Boot brings its own
class loader. That is not a quirk but the price of keeping the archives as
archives and still having them found.</p><h3 id="the-second-place-where-the-same-thing-happens">The second place where the same thing happens</h3><p>The annotation scan is not the only place where the bootstrap depends on
several archives contributing. Part 3 assembled the base resource of the web
context by hand, because several archives bring a<code>META-INF/resources</code> branch:</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="n">Collections</span><span class="p">.</span><span class="na">list</span><span class="p">(</span><span class="n">Application</span><span class="p">.</span><span class="na">class</span><span class="p">.</span><span class="na">getClassLoader</span><span class="p">()</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">.</span><span class="na">getResources</span><span class="p">(</span><span class="s">"META-INF/resources/"</span><span class="p">))</span></span></span></code></pre></div></div><p>At startup, this line writes to the log how many roots it found. The comparison
of both forms:</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>Thin Distribution: Serving static web resources from 5 classpath root(s)
Fat JAR: Serving static web resources from 1 classpath root(s)</code></pre></div><p>Both serve the same files.<strong>Only the merging happens at different points in
time</strong> — for the Fat JAR at build time through Shade, for the Thin Distribution
at startup through the class loader. It is the same difference as with the
classpath, made visible in a second place.</p><p>This also makes clear which risk the Fat JAR carries at this point: if Shade
lays two identically named files on top of each other, one wins and the other
is gone. In this application, that does not happen — the overlapping resources
are exclusively license and manifest files, not one of which is ever served.
Verified, not assumed: the build reports the overlaps, and the list contains<code>META-INF/NOTICE.md</code>,<code>META-INF/LICENSE</code>, and relatives.</p><h3 id="what-this-means-for-the-choice">What this means for the choice</h3><p>It does<strong>not</strong> mean that the Fat JAR is the better form. It means that the
statement from Part 3 needs to be phrased more precisely than it was there:</p><div class="pull-quote"><p>The problem is not<code>java -jar</code> but a classpath that contains less than the
scanner needs to find.</p></div><p>The Thin Distribution solves this with an explicit classpath, the Fat JAR with
a complete archive. Both are answers to the same condition.</p><p>And it means that a packaging takes on tasks that would otherwise occur at
runtime — which also means it can fail where at runtime there was nothing to
fail. The next chapter demonstrates what that looks like.</p><h2 id="trouble-spots-in-the-fat-jar">Trouble spots in the Fat JAR</h2><p>128 archives have 128<code>META-INF</code> directories. One archive has one. What happens
in between is the actual content of this chapter — and all four trouble spots
share the same property:<strong>they do not announce themselves at build time.</strong></p><h3 id="service-providers-overwrite-each-other">Service providers overwrite each other</h3><p>The<code>ServiceLoader</code> finds providers via files under<code>META-INF/services/</code>, named
after the interface. In this application there are 36 such files with a
combined 85 entries — spread across 27 distinct names.<strong>Five names occur more
than once.</strong></p><p>Side by side in<code>lib/</code>, that is no problem: every archive has its own<code>META-INF</code>, and the<code>ServiceLoader</code> reads all occurrences via<code>getResources()</code>.
In a shared archive, the same path can exist only once.</p><p>The most significant of the five names:</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>META-INF/services/jakarta.servlet.ServletContainerInitializer
atmosphere-runtime-3.0.5 AnnotationScanningServletContainerInitializer
ContainerInitializer
flow-server-25.2.6 LookupServletContainerInitializer
RouteRegistryInitializer
ErrorNavigationTargetInitializer
AnnotationValidator
WebComponentExporterAwareValidator
WebComponentConfigurationRegistryInitializer
VaadinAppShellInitializer
jetty-ee11-websocket-…-server JakartaWebSocketServletContainerInitializer</code></pre></div><p>There sits<code>RouteRegistryInitializer</code> — exactly the class whose absence Part 3
diagnosed. And in the same situation is<code>org.eclipse.jetty.ee11.webapp.Configuration</code>, claimed by four archives; one of
them supplies the annotation scanner, another the twelve base configurations of
the web context.</p><p>The<code>ServicesResourceTransformer</code> merges these files. The evidence that it does
its job:</p><table><thead><tr><th/><th style="text-align: right">Files</th><th style="text-align: right">Provider entries</th></tr></thead><tbody><tr><td>raw in<code>lib/</code></td><td style="text-align: right">36</td><td style="text-align: right">85</td></tr><tr><td>in the Fat JAR</td><td style="text-align: right">27</td><td style="text-align: right"><strong>85</strong></td></tr></tbody></table><p>Nine files fewer, not one entry lost.</p><h4 id="what-happens-without-it">What happens without it</h4><p>This can be demonstrated. Take the same Fat JAR and replace each of the five
multiply assigned service files with just<strong>one</strong> of its versions — exactly
what merging without the transformer produces — and the result is:</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 -jar fat-naive.jar
[main] INFO Serving static web resources from 1 classpath root(s)
[main] INFO Started oejs.Server@272ed83b{STARTING}[12.1.12,sto=0] @61ms
[main] INFO Embedded Jetty serving http://127.0.0.1:8097/
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8097/
500
java.lang.NullPointerException: Cannot invoke
"com.vaadin.flow.server.StaticFileHandler.serveStaticResource(…)"
because "this.staticFileHandler" is null</code></pre></div><p><strong>This is literally the same failure as pitfall 15 from Part 3</strong> — the same
exception, the same startup time in the double-digit millisecond range, the
same responding but unusable application:</p><table><thead><tr><th/><th style="text-align: right">Startup time</th><th>HTTP</th></tr></thead><tbody><tr><td>Fat JAR, merged</td><td style="text-align: right">1,651 ms</td><td>200</td></tr><tr><td>Fat JAR without transformer</td><td style="text-align: right"><strong>61 ms</strong></td><td>500</td></tr><tr><td>Thin Distribution</td><td style="text-align: right">1,709 ms</td><td>200</td></tr><tr><td><em>Part 3:<code>java -jar</code> with<code>Class-Path</code></em></td><td style="text-align: right"><em>139 ms</em></td><td><em>exception</em></td></tr></tbody></table><p>Two completely different causes — there an incomplete classpath, here a
destroyed provider directory — lead to the identical symptom. Whoever has seen
this signature once will recognize it again:<strong>if the server starts in under
200 milliseconds, no scan has taken place.</strong></p><h3 id="signatures-break">Signatures break</h3><p>Signed archives carry files with the extensions<code>.SF</code>,<code>.DSA</code>, and<code>.RSA</code> under<code>META-INF</code>. They describe checksums of the entries of the archive they were
created for. Carried over into a different archive, they no longer match its
content, and the JVM rejects the archive with a<code>SecurityException</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;exclude&gt;</span>META-INF/*.SF<span class="nt">&lt;/exclude&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;exclude&gt;</span>META-INF/*.DSA<span class="nt">&lt;/exclude&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;exclude&gt;</span>META-INF/*.RSA<span class="nt">&lt;/exclude&gt;</span></span></span></code></pre></div></div><p>The side effect must be named: the signature is gone. Whoever needs it re-signs
the Fat JAR after the build — the individual signatures cannot be salvaged.</p><h3 id="module-descriptors-collide">Module descriptors collide</h3><p>An archive can carry a<code>module-info.class</code>. 128 archives carry several. Laid on
top of each other, one remains — describing the<code>requires</code> and<code>exports</code> of a
module that no longer exists in that form.</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;exclude&gt;</span>module-info.class<span class="nt">&lt;/exclude&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;exclude&gt;</span>META-INF/versions/*/module-info.class<span class="nt">&lt;/exclude&gt;</span></span></span></code></pre></div></div><p>The second line is easily forgotten: multi-release archives place their module
descriptor under<code>META-INF/versions/9/</code>, not in the root.</p><h3 id="multi-release-versions-disappear">Multi-release versions disappear</h3><p>The opposite pole — here nothing is excluded; something is set. The built Fat
JAR contains<strong>1,576 entries</strong> under<code>META-INF/versions/</code>. Without<code>Multi-Release: true</code> in the manifest, the JVM skips them all.</p><p>One exclusion too many and one manifest entry too few thus lead to opposite
failures, and both stay silent.</p><h3 id="identically-named-resources">Identically named resources</h3><p>That leaves the general case: two archives, the same file, different content.
Shade reports this as a warning and keeps one. When building this application,
seven groups are affected:</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> 10 archives → META-INF/NOTICE.md
7 archives → LICENSE
7 archives → META-INF/LICENSE.md
6 archives → META-INF/LICENSE.txt
3 archives → META-INF/LICENSE, META-INF/NOTICE
2 archives → META-INF/versions/9/OSGI-INF/MANIFEST.MF
128 archives → META-INF/MANIFEST.MF</code></pre></div><p>Exclusively license, notice, and manifest files. None of them is read at
runtime, none is served — in this case the warning is without consequence.</p><p>It must be read anyway, every single time. The list does not say that overlaps
are harmless; it says that they are harmless<strong>here</strong>. If a dependency were
added that brings a configuration file under a common name, it would appear in
the same list, between the license files, and would be overlooked.</p><p><strong>The legal side finding belongs here too:</strong> ten archives ship a<code>META-INF/NOTICE.md</code>, nine of them disappear. Whoever redistributes third-party
software also passes on the notice obligations. A Fat JAR does not fulfill them
by itself — the merged license collection is a separate build step, which this
series covers via the SBOM it already generates.</p><h2 id="delivering-the-fat-jar">Delivering the Fat JAR</h2><p>Delivering a Fat JAR is the shortest section of this part, and that is its most
important characteristic.</p><h3 id="the-procedure">The procedure</h3><p>The Fat JAR is not deposited as a bare file but as a<strong>release of the same
shape</strong> as the Thin Distribution — just without<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>vaadinapp-00.01.00/
├── VERSION
├── bin/
│ └── start.sh same file as in the thin distribution
└── app/
└── vaadinapp.jar 28,89 MB</code></pre></div><p>That is not a formality. The start script from Chapter 9 sets</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="nv">CLASSPATH</span><span class="o">=</span><span class="s2">"</span><span class="nv">$APP_HOME</span><span class="s2">/app/*:</span><span class="nv">$APP_HOME</span><span class="s2">/lib/*"</span></span></span></code></pre></div></div><p>as the classpath. If<code>lib/</code> is missing, the second half comes up empty — and
the one complete archive in<code>app/</code> is all the annotation scan needs. Exactly
the property from Chapter 4.<strong>The same file starts both forms</strong>, byte for byte
unchanged.</p><p>The release is produced in the same profile as the archive:</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">mvn -Pproduction,fatjar package</span></span><span class="line"><span class="cl"><span class="c1"># → target/vaadinapp-00.01.00-fatdist.tar.gz 25,807,851 Bytes</span></span></span></code></pre></div></div><p>The delivery afterwards is the same as in Part 5, Chapter 3:</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">scp embedded-jetty/target/vaadinapp-00.01.00-fatdist.tar.gz<span class="se">\</span></span></span><span class="line"><span class="cl"> sven@demo.svenruppert.com:/tmp/</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">ssh sven@demo.svenruppert.com<span class="s1">'</span></span></span><span class="line"><span class="cl"><span class="s1"> STAMP=$(date -u +%Y-%m-%d-%H%M)-fat</span></span></span><span class="line"><span class="cl"><span class="s1"> sudo install -d -m 750 -o root -g vaadinapp /opt/vaadinapp/releases/$STAMP</span></span></span><span class="line"><span class="cl"><span class="s1"> sudo tar -xzf /tmp/vaadinapp-00.01.00-fatdist.tar.gz \</span></span></span><span class="line"><span class="cl"><span class="s1"> -C /opt/vaadinapp/releases/$STAMP --strip-components=1</span></span></span><span class="line"><span class="cl"><span class="s1"> sudo chown -R root:vaadinapp /opt/vaadinapp/releases/$STAMP</span></span></span><span class="line"><span class="cl"><span class="s1"> sudo ln -sfn releases/$STAMP /opt/vaadinapp/current</span></span></span><span class="line"><span class="cl"><span class="s1"> sudo systemctl restart vaadinapp'</span></span></span></code></pre></div></div><p><strong>The systemd unit is not touched in the process.</strong> It calls<code>/opt/vaadinapp/current/bin/start.sh</code>, and which form lies behind it is none of
its business. That is the payoff of the cut from Chapter 9: because the release
says how it wants to be started, the change of form is the same procedure as
any release change — one symlink and one restart.</p><p>And it is the precondition for Part 5, Chapter 6 to be able to measure at all:
same machine, same unit, same environment, same hardening. The only difference
is the content of the release directory.</p><h3 id="what-stands-out--and-what-it-costs">What stands out — and what it costs</h3><p>The shape of the release is the same; its content is not. Where the Thin
Distribution has a<code>lib/</code> with 127 named archives, here stands one file — and
with it, the question of which libraries belong to the application disappears.
It is not hard to answer — it can no longer be asked:</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">$ ls /opt/vaadinapp/current/</span></span><span class="line"><span class="cl">VERSION app bin</span></span><span class="line"><span class="cl">$ ls /opt/vaadinapp/current/app/</span></span><span class="line"><span class="cl">vaadinapp.jar</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">$ unzip -l /opt/vaadinapp/current/app/vaadinapp.jar<span class="p">|</span> tail -1</span></span><span class="line"><span class="cl">17,904 files</span></span></code></pre></div></div><p>Just under eighteen thousand entries and no provenance. Whether the version of
BouncyCastle in this archive is the one for which a security advisory has just
appeared can no longer be determined here — the classes are inside, the version
numbers are not.</p><p>Against this there is exactly one effective remedy, and it belongs to due
diligence anyway: the<strong>SBOM</strong> the reactor generates with<code>cyclonedx</code>. It is
written at build time, when the provenance is still known, and must be kept
together with the artifact. For the Fat JAR it is not an accessory but the only
remaining source of this information.</p><h3 id="the-difference-from-the-war-in-part-1">The difference from the WAR in Part 1</h3><p>For updates, Part 1 decided in favor of copying and against the symlink — on
the grounds that for a single file, a symlink offers nothing that<code>mv</code> does not
already provide. The same holds for the Fat JAR: here too, one could work
without a symlink.</p><p>It appears in the commands above anyway, and the reason lies not with this
form but with the procedure: from Part 5, Chapter 3 on, the reference server
works with release directories, and the Fat JAR hooks in there as one of
several releases. Only this way is the switch between the two forms the same
procedure that Part 5, Chapter 4 describes — and not a special path that exists
solely for the measurement.</p><p>What is remarkable is how little remains that distinguishes the two forms. Not
the unit, not the start script, not the rollout procedure, not the hardening.
Only the content of one directory. That is exactly why the choice between them
is not an architecture decision but an operations decision — which is what
Part 5, Chapter 7 comes down to.</p><h2 id="the-thin-distribution">The Thin Distribution</h2><p>The second answer keeps the separation the Fat JAR dissolves, and organizes it.
What Part 3 left behind as<code>app.jar</code> next to a<code>lib/</code> is given a form here:</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>vaadinapp-00.01.00/
├── VERSION
├── bin/
│ └── start.sh
├── app/
│ └── vaadinapp.jar 10,686 Bytes
└── lib/
├── bcprov-jdk18on-1.84.jar
├── flow-server-25.2.6.jar
└── … 127 archives, 28.4 MB</code></pre></div><p>Three directories whose division is not aesthetic but<strong>maps the speed of
change</strong>.</p><h3 id="bin"><code>bin/</code></h3><p>Here lies how this release wants to be started. It is not the machine that
knows this but the delivery itself — which means it can also be started outside
systemd, during development, on another computer, in a container. Chapter 9
justifies what belongs in this script and what does not.</p><h3 id="app"><code>app/</code></h3><p>An archive of ten kilobytes. It contains the bootstrap and nothing else.</p><p>That this directory is created for a single file looks like ceremony and is
the heart of the matter:<strong>this is the part that changes.</strong> A bug fix to the
application changes<code>app/</code> and leaves<code>lib/</code> untouched. If the two were merged,
this distinction could no longer be made — and with it would go the practical
advantage of this form, which Part 5, Chapter 6 measures in bytes.</p><p><em>A note on this series&rsquo; application:</em> the application code here lives in the<code>core</code> module and therefore in<code>lib/</code>, not in<code>app/</code>. That is a consequence of
the reactor structure from Part 3 —<code>core</code> is a dependency of<code>embedded-jetty</code>,
and<code>copy-dependencies</code> treats it like any other. In a project that keeps
application code and bootstrap in one module, it would live in<code>app/</code>. This
changes nothing about the statement, but it shifts the boundary: in this
application it is 2.47 MB in<code>lib/</code> that change frequently, instead of ten
kilobytes in<code>app/</code>.</p><h3 id="lib"><code>lib/</code></h3><p>127 archives, each under its own name with its own version number.</p><p>This is the difference from the Fat JAR that is easiest to demonstrate. The
question &ldquo;which version of BouncyCastle is on the server?&rdquo; answers itself here
like this:</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">$ ls /opt/vaadinapp/current/lib<span class="p">|</span> grep bcprov</span></span><span class="line"><span class="cl">bcprov-jdk18on-1.84.jar</span></span></code></pre></div></div><p>With the Fat JAR, the same question cannot be answered. Not harder —<strong>cannot
be answered</strong>: the classes are inside, their provenance is not. One can search
for a class and infer which library contributed it; the version is no longer
written anywhere.</p><p>For a machine you administer yourself, this is the decisive difference. When a
security advisory appears for a library, the first question is always the same:<em>am I affected?</em> The Thin Distribution answers it with an<code>ls</code>.</p><h3 id="version"><code>VERSION</code></h3><p>One line with the version number, filled in by Maven at build time. It is
needed because the release directories on the server are named after the point
in time and not after the version — the justification is in Part 5, Chapter 4.
Without this file, a release would not reveal which version it contains.</p><h3 id="what-this-form-does-not-provide">What this form does not provide</h3><p>It is not atomic. 128 files cannot be replaced in one step, and an interrupted
copy leaves behind a state that is neither the old one nor the new one. That is
exactly where the procedure in Part 5, Chapter 4 comes from, and exactly why it
appears in this part and not already in Part 1.</p><p>And it is sensitive to anything that settles unnoticed into<code>lib/</code>. Part 3
learned that the hard way: the classpath wildcard<code>lib/*</code> takes every file
ending in<code>.jar</code>, including the eleven AppleDouble files that macOS&rsquo;s<code>tar</code> had
thrown in. The wildcard checks nothing; it only expands.</p><h2 id="separating-application-archive-and-libraries">Separating application archive and libraries</h2><p>The separation from Chapter 7 must come into being at build time. Maven offers
two ways to do this, and they are not mutually exclusive — the series uses
both, for different purposes.</p><h3 id="the-classpath-is-the-actual-decision">The classpath is the actual decision</h3><p>First, the observation from which everything else follows: a classpath is an<strong>ordered list</strong>. Java searches it from the front and takes the first hit.</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">java -cp<span class="s1">'app/*:lib/*'</span> com.svenruppert.flow.Application</span></span></code></pre></div></div><p>Two entries, in this order. If the same class exists in<code>app/</code> and in<code>lib/</code>,<code>app/</code> wins. That is no accident but the basis for being able to replace a
single class without touching the library it comes from — a tool for
emergencies, not for everyday use, but it exists only as long as the components
stay separate.</p><p>The wildcard<code>*</code> is a peculiarity of the JVM here, not of the shell. Java
itself expands it to all<code>.jar</code> files in the directory,<strong>in no defined
order</strong>. Whoever needs a particular order within<code>lib/</code> cannot enforce it this
way; the archives must be listed individually. For this application, that is of
no consequence — there is no class assigned twice — but it is the reason the
quotation marks must surround the expression: without them, the shell expands,
and Java receives a list of file names instead of a classpath.</p><h3 id="copy-dependencies"><code>copy-dependencies</code></h3><p>The simpler way.<code>maven-dependency-plugin</code> copies everything needed at runtime
into a directory:</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;plugin&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>org.apache.maven.plugins<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>maven-dependency-plugin<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;executions&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;execution&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;id&gt;</span>collect-libraries<span class="nt">&lt;/id&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;phase&gt;</span>prepare-package<span class="nt">&lt;/phase&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;goals&gt;&lt;goal&gt;</span>copy-dependencies<span class="nt">&lt;/goal&gt;&lt;/goals&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;configuration&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;outputDirectory&gt;</span>${project.build.directory}/lib<span class="nt">&lt;/outputDirectory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;includeScope&gt;</span>runtime<span class="nt">&lt;/includeScope&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;overWriteIfNewer&gt;</span>true<span class="nt">&lt;/overWriteIfNewer&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/configuration&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/execution&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/executions&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/plugin&gt;</span></span></span></code></pre></div></div><p><code>includeScope=runtime</code> is the line that counts. It takes<code>compile</code> and<code>runtime</code> along and leaves<code>provided</code> and<code>test</code> out. In the WAR from Parts 1
and 2, the Servlet API was<code>provided</code> — the container supplied it. Here it is<code>compile</code>, because the application itself is the container. This one scope
change is the entire difference between the two modules; everything else
follows from it.</p><p>The result is exactly what Part 3 delivered: an application archive and a<code>lib/</code> next to it. For development, that is entirely sufficient.</p><h3 id="why-that-is-not-enough-for-a-delivery">Why that is not enough for a delivery</h3><p>Three things are missing.</p><p><strong>A place.</strong> After the build,<code>target/</code> also contains test classes, Checkstyle
reports, and intermediate artifacts. What gets delivered must be named as a
unit, not picked out of a build directory.</p><p><strong>The start script.</strong> It lives in<code>src/main/dist/bin</code> and is not touched by<code>copy-dependencies</code> — the plugin copies dependencies, not the project&rsquo;s own
files.</p><p><strong>The transfer.</strong> A directory cannot be copied like a file. It needs a
transport form, and this transport form must preserve the permissions — in
particular the execute permission on the start script, which an ordinary<code>zip</code> loses.</p><p>That is why Part 5, Chapter 2 adds a second step that turns the components into
a delivery. First, however, the question the start script raises, and which
reaches further than Maven: what belongs in it at all?</p><h2 id="the-start-script">The start script</h2><p>Since Part 1, the complete start command has been in the systemd unit:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">ini</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-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/var/lib/vaadinapp/.sdkman/candidates/java/current/bin/java \</span></span></span><span class="line"><span class="cl"><span class="s"> -Djava.io.tmpdir=/var/lib/vaadinapp/work \</span></span></span><span class="line"><span class="cl"><span class="s"> -Dapp.storage.dir=/var/lib/vaadinapp/data \</span></span></span><span class="line"><span class="cl"><span class="s"> --add-exports java.base/jdk.internal.misc=ALL-UNNAMED \</span></span></span><span class="line"><span class="cl"><span class="s"> --enable-native-access=ALL-UNNAMED \</span></span></span><span class="line"><span class="cl"><span class="s"> -XX:MaxRAMPercentage=75 \</span></span></span><span class="line"><span class="cl"><span class="s"> -XX:+ExitOnOutOfMemoryError \</span></span></span><span class="line"><span class="cl"><span class="s"> -cp /opt/vaadinapp/app.jar:/opt/vaadinapp/lib/* \</span></span></span><span class="line"><span class="cl"><span class="s"> com.svenruppert.flow.Application</span></span></span></code></pre></div></div><p>Nine lines that all seem to have the same right to be there. They do not.</p><h3 id="the-cut-follows-from-what-changes-together">The cut follows from what changes together</h3><p>These nine lines can be sorted, by a single question:<em>when does this change?</em></p><table><thead><tr><th>Entry</th><th>changes</th><th>belongs</th></tr></thead><tbody><tr><td><code>-cp …</code></td><td>with<strong>every</strong> release</td><td>to the application</td></tr><tr><td>Main class</td><td>with the code</td><td>to the application</td></tr><tr><td><code>--add-exports java.base/jdk.internal.misc</code></td><td>when EclipseStore no longer needs it</td><td>to the application</td></tr><tr><td><code>--enable-native-access</code></td><td>ditto</td><td>to the application</td></tr><tr><td><code>-Djava.io.tmpdir=…</code></td><td>when the machine is set up differently</td><td>to the machine</td></tr><tr><td><code>-Dapp.storage.dir=…</code></td><td>ditto</td><td>to the machine</td></tr><tr><td><code>-XX:MaxRAMPercentage=75</code></td><td>when the machine gets more memory</td><td>to the machine</td></tr><tr><td><code>-XX:+ExitOnOutOfMemoryError</code></td><td>with the operations decision</td><td>to the machine</td></tr><tr><td>Path to<code>java</code></td><td>with the JDK on this machine</td><td>to the machine</td></tr></tbody></table><p>The upper half are<strong>statements about this build state</strong>.<code>--add-exports java.base/jdk.internal.misc=ALL-UNNAMED</code> is there because EclipseStore reaches
into JDK internals; that is a property of this release&rsquo;s dependencies, not a
property of the server. Keeping it on the machine means: a release that needs
one additional option demands a change to the unit — delivery and operations
are coupled, although they would not have to be.</p><p>The lower half are<strong>statements about this machine</strong>. They do not belong in an
archive that could be unpacked on several machines.</p><h3 id="what-follows-from-this-1">What follows from this</h3><p>The upper half moves into<code>bin/start.sh</code> and is versioned with the release.
The lower half stays in the unit:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">ini</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-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">Environment</span><span class="o">=</span><span class="s">JAVA_HOME=/var/lib/vaadinapp/.sdkman/candidates/java/current</span></span></span><span class="line"><span class="cl"><span class="na">Environment</span><span class="o">=</span><span class="s">"JAVA_OPTS=-Djava.io.tmpdir=/var/lib/vaadinapp/work \</span></span></span><span class="line"><span class="cl"><span class="s"> -Dapp.storage.dir=/var/lib/vaadinapp/data \</span></span></span><span class="line"><span class="cl"><span class="s"> -XX:MaxRAMPercentage=75 \</span></span></span><span class="line"><span class="cl"><span class="s"> -XX:+ExitOnOutOfMemoryError"</span></span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/opt/vaadinapp/current/bin/start.sh</span></span></span></code></pre></div></div><p>And in the script:</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="nv">APP_HOME</span><span class="o">=</span><span class="k">$(</span><span class="nv">CDPATH</span><span class="o">=</span><span class="nb">cd</span> --<span class="s2">"</span><span class="k">$(</span>dirname --<span class="s2">"</span><span class="nv">$0</span><span class="s2">"</span><span class="k">)</span><span class="s2">/.."</span><span class="o">&amp;&amp;</span><span class="nb">pwd</span><span class="k">)</span></span></span><span class="line"><span class="cl"/></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><span class="line"><span class="cl"><span class="nv">APP_OPTS</span><span class="o">=</span><span class="s2">"--add-exports java.base/jdk.internal.misc=ALL-UNNAMED \</span></span></span><span class="line"><span class="cl"><span class="s2"> --enable-native-access=ALL-UNNAMED"</span></span></span><span class="line"><span class="cl"><span class="nv">JAVA_OPTS</span><span class="o">=</span><span class="si">${</span><span class="nv">JAVA_OPTS</span><span class="k">:-</span><span class="si">}</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="nv">CLASSPATH</span><span class="o">=</span><span class="s2">"</span><span class="nv">$APP_HOME</span><span class="s2">/app/*:</span><span class="nv">$APP_HOME</span><span class="s2">/lib/*"</span></span></span><span class="line"><span class="cl"/></span><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><span class="se">\</span></span></span><span class="line"><span class="cl"> -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><strong>The gain can be read off in one place:</strong><code>ExecStart</code> has become one line and
never changes again. A release that needs different JVM options brings them
along. A rollback to an older release brings its options back — without anyone
having to remember.</p><p><strong>The price is just as concrete:</strong><code>systemctl cat vaadinapp</code> no longer shows
what is actually executed. Whoever wants to know reads<code>/opt/vaadinapp/current/bin/start.sh</code> in addition. A diagnostic path from
Part 1 becomes one step longer, and that is a real loss, not a mere annoyance.</p><h3 id="three-details-that-are-not-a-matter-of-taste">Three details that are not a matter of taste</h3><p><strong><code>exec</code> — otherwise systemd watches the wrong thing.</strong> Without<code>exec</code>, the
shell remains as the parent process, systemd sees it as the main process, and
the<code>SIGTERM</code> on restart reaches the shell instead of the JVM.<code>SuccessExitStatus=143</code> from Part 1 then no longer describes what the service
does. The proof that<code>exec</code> takes effect is easy to produce — the process that
was started as a script<strong>is</strong> the JVM afterwards:</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>$ ./bin/start.sh &amp;
[1] 71668
$ ps -o pid,comm= -p 71668
71668 …/candidates/java/current/bin/java
$ pgrep -f 'bin/start.sh' | wc -l
0</code></pre></div><p>The same process ID, a different program. The shell no longer exists.</p><p><strong>The wildcard belongs to Java, not to the shell.</strong><code>-cp "$CLASSPATH"</code> with
quotation marks. Without them, the shell expands<code>lib/*</code> into 127 file names,
and Java receives 127 arguments instead of one classpath.</p><p><strong>The script writes nothing next to itself.</strong> No PID file, no redirection into
a log file. Under<code>ProtectSystem=strict</code> from Part 1, the release directory is
read-only for the service; a write attempt aborts the start. Logs go to the
journal, as they have since Part 1.</p><h3 id="where-the-secrets-go">Where the secrets go</h3><p>Unchanged, into the unit, via<code>systemd-creds</code>. The script neither reads nor
names them. Had the assignment been made differently — secrets into the
release — a login secret would sit in an archive that gets built, copied, and
archived. The rule by which the cut was made here prevents that by itself: a
secret does not change with the release.</p><h2 id="outlook-on-part-5">Outlook on Part 5</h2><p>Both forms now exist, built. What distinguishes them is not yet decided at this
point — and cannot be decided as long as neither of them has been deployed.</p><h3 id="what-remains-open">What remains open</h3><p><strong>The delivery itself.</strong> A Fat JAR is one file; an<code>scp</code> is enough. A Thin
Distribution consists of 128 files, and there is no way to replace 128 files in
one step. Exactly from this follows a procedure Part 1 had explicitly deferred:
releases in numbered directories and a<code>current</code> symlink that moves atomicity
from the files to the name.</p><p><strong>The comparison.</strong> The question this series raised with &ldquo;Fat JAR or Thin
Distribution?&rdquo; is a question of measurements — downtime, startup time, memory,
change volume. Without a reference server, it cannot be answered.</p><p><strong>The rollback.</strong> What happens when a release turns out to be no good. It is
the actual reason for the entire release procedure, and it is done in two
lines — provided the right things were done beforehand.</p><h3 id="what-is-already-taking-shape">What is already taking shape</h3><p>Two things from this part carry through into the measurement.</p><p><strong>The two releases have the same shape.</strong><code>VERSION</code>,<code>bin/</code>,<code>app/</code>, and only
in the Thin Distribution a<code>lib/</code>. The start script is byte for byte the same
file. So the systemd unit does not distinguish the forms, and switching between
them is the same motion as any release change. Without this sameness, a
comparison under equal conditions would not be possible at all — one would then
be comparing two setups instead of two packagings.</p><p><strong>The classpath decides more than the file size.</strong> Chapter 4 showed that one
archive is enough exactly when it is complete; Chapter 5, that the same
application comes up in 61 milliseconds on a destroyed provider directory and
still can do nothing. Whoever looks at the startup times in Part 5 should keep
that number in mind.</p><h3 id="an-expectation-i-write-down-beforehand">An expectation I write down beforehand</h3><p>So that it remains verifiable: I expect that<strong>memory footprint and startup
time do not differ between the two forms</strong>. The same classes, the same
bootstrap, the same runtime — the packaging should change nothing about that.</p><p>One of the two expectations holds. The other does not, and the difference is
large enough that it cannot be argued away. Which one it is, is in Part 5,
Chapter 6.</p>
]]></content:encoded><category>Java</category><category>Vaadin</category><media:content url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-4-fat-jar-and-thin-distribution-hero.png" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-4-fat-jar-and-thin-distribution-hero.png"/><enclosure url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-4-fat-jar-and-thin-distribution-hero.png" type="image/jpeg" length="0"/></item></channel></rss>