<?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>Operations on Sven Ruppert</title><link>https://svenruppert.com/tags/operations/</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/operations/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/operations/</link></image><lastBuildDate>Thu, 24 Sep 2026 10:00:00 +0200</lastBuildDate><item><title>Vaadin Deployment on Hetzner — Part 5: Deliver, Switch, Compare</title><link>https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-5-deliver-switch-compare/</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-5-deliver-switch-compare/</guid><description>Shipping, switching, and comparing releases of a Vaadin application: rollback as a feature, symlink switching, and honest measurements of both delivery forms.</description><content:encoded>&lt;![CDATA[<p>Fifth part of the series<em>Vaadin – Deployment on Hetzner</em>. The two packagings from Part 4 go onto the server: releases in numbered directories, a<code>current</code> symlink, a rollback in two lines — and the measurement that decides between them. All values come from an actual run on a Debian 13 server.</p><h2 id="the-state-after-part-4">The State After Part 4</h2><p>Part 4 built two packagings of the same application. What now sits in the
build 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>embedded-jetty/target/
├── vaadinapp-00.01.00-thin.tar.gz 130 files, 127 archives
└── vaadinapp-00.01.00-fatdist.tar.gz 3 files, 1 archive with 17,904 entries</code></pre></div><p>Both share the same shape —<code>VERSION</code>,<code>bin/</code>,<code>app/</code>, plus<code>lib/</code> for the Thin
Distribution — and<strong>the same start script, byte-for-byte identical</strong>. It puts<code>app/*:lib/*</code> on the classpath; if<code>lib/</code> is missing, the second half resolves
to nothing, and the one complete archive satisfies the annotation scan.</p><p>This sameness is no accident but the precondition for everything this part
does. Because the releases share the same shape, the systemd unit does not
distinguish them. And because it does not distinguish them, switching between
the forms is<strong>the same operation</strong> as any release switch: a symlink and a
restart.</p><h3 id="what-part-4-left-open">What Part 4 Left Open</h3><figure><img src="/images/2026/09/vaadin-deployment-on-hetzner-part-5-deliver-switch-compare-teil04-verpackungen.png" alt="Two releases of the same shape" loading="lazy" decoding="async"/><p><em>Figure 1: Both forms as releases of the same shape — only lib/ distinguishes them.</em></p><p>Two things.</p><p><strong>The delivery.</strong> A directory cannot be copied like a file. What comes into
being on the server comes into being from 128 files, and there is no way to
replace 128 files in a single step.</p><p><strong>The answer.</strong> The original title of this part was &ldquo;Fat JAR or Thin
Distribution?&rdquo; — the question is answered only here, with measurements from the
reference server. This much can be said in advance: one of the two metrics I
had previously assumed to be equal is not.</p><h3 id="a-promise-from-part-1">A Promise from Part 1</h3><p>Part 1, step 29 explicitly decided against the symlink for updates — releases
are archived, the WAR is copied. Alongside that stood the sentence that the
symlink approach would come later, &ldquo;where it unfolds its purpose with the
directory distribution.&rdquo; Part 3, chapter 17 repeated it.</p><p>Chapter 4 redeems that promise — and explains why it would indeed have gained
nothing for the WAR.</p><h3 id="the-code-state-for-this-part">The Code State for This Part</h3><p>The same as for Part 4: tag<strong><code>teil-04</code></strong>. The split into two articles concerns
the text, not the repository —<code>git diff teil-03 teil-04</code> shows both
together.</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 tag<code>teil-04</code>, no bill of materials ships inside the release yet. The
project does generate an SBOM, but it stays in the build directory. Why that is
a deficiency and how it gets fixed is the subject of a separate article.</p><h2 id="building-the-distribution-with-maven">Building the Distribution with Maven</h2><p>The components become a deliverable. For that, a second step joins<code>copy-dependencies</code> from Part 4, chapter 8.</p><h3 id="assembly--the-right-tool-here">Assembly — the Right Tool Here</h3><p>The<code>maven-assembly-plugin</code> was eliminated as a route to the fat JAR in Part 4, chapter 3,
because its<code>jar-with-dependencies</code> descriptor does not merge service providers. For
a<strong>directory</strong>, this problem does not arise: nothing is merged, because nothing
is flattened. Every archive remains an archive with its own<code>META-INF</code>.</p><p>The descriptor lives at<code>src/main/assembly/thin.xml</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;assembly&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;id&gt;</span>thin<span class="nt">&lt;/id&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;formats&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;format&gt;</span>dir<span class="nt">&lt;/format&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;format&gt;</span>tar.gz<span class="nt">&lt;/format&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/formats&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;includeBaseDirectory&gt;</span>true<span class="nt">&lt;/includeBaseDirectory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;baseDirectory&gt;</span>vaadinapp-${project.version}<span class="nt">&lt;/baseDirectory&gt;</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="nt">&lt;fileSets&gt;</span></span></span><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.basedir}/src/main/dist/bin<span class="nt">&lt;/directory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;outputDirectory&gt;</span>bin<span class="nt">&lt;/outputDirectory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;fileMode&gt;</span>0755<span class="nt">&lt;/fileMode&gt;</span></span></span><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;/fileSets&gt;</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="nt">&lt;files&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;file&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;source&gt;</span>${project.basedir}/src/main/dist/VERSION<span class="nt">&lt;/source&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;outputDirectory&gt;</span>.<span class="nt">&lt;/outputDirectory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;filtered&gt;</span>true<span class="nt">&lt;/filtered&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/file&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;file&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;source&gt;</span>${project.build.directory}/${project.build.finalName}.jar<span class="nt">&lt;/source&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;outputDirectory&gt;</span>app<span class="nt">&lt;/outputDirectory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;destName&gt;</span>vaadinapp.jar<span class="nt">&lt;/destName&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/file&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/files&gt;</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="nt">&lt;dependencySets&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;dependencySet&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;outputDirectory&gt;</span>lib<span class="nt">&lt;/outputDirectory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;useProjectArtifact&gt;</span>false<span class="nt">&lt;/useProjectArtifact&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;scope&gt;</span>runtime<span class="nt">&lt;/scope&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/dependencySet&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/dependencySets&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/assembly&gt;</span></span></span></code></pre></div></div><p>Four places carry a decision.</p><p><strong><code>dir</code> and<code>tar.gz</code> at the same time.</strong> The directory afterwards sits in<code>target/</code> and
can be transferred with<code>rsync</code> or inspected directly. The<code>.tgz</code> is
the transport form for everything else. Both come from the same descriptor so they
cannot drift apart.</p><p><strong><code>tar.gz</code>, not<code>zip</code>.</strong> A ZIP archive knows no execute permission in the
POSIX sense. The start script would arrive without its<code>x</code> bit, and the service would not start.
For the case of long file names among the dependencies,<code>&lt;tarLongFileMode&gt;posix&lt;/tarLongFileMode&gt;</code> is set in the plugin configuration — the
default<code>ustar</code> aborts on paths longer than 100 characters.</p><p><strong><code>useProjectArtifact=false</code>.</strong> Without this line, the application archive would end up in<code>lib/</code>, among the dependencies. Instead it is placed into<code>app/</code>
by hand — exactly the separation from Part 4, chapter 7.</p><p><strong><code>filtered</code> on the<code>VERSION</code> file.</strong> Maven substitutes<code>${project.version}</code> at build time.
The same number thus appears in the archive and in the directory name without
having to be maintained twice.</p><h3 id="what-comes-out">What Comes Out</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">$ mvn -Pproduction,thin package</span></span><span class="line"><span class="cl">$ ls -l embedded-jetty/target/</span></span><span class="line"><span class="cl"><span class="m">26676831</span> vaadinapp-00.01.00-thin.tar.gz</span></span><span class="line"><span class="cl"><span class="m">10686</span> vaadinapp-embedded-jetty-00.01.00.jar</span></span><span class="line"><span class="cl"> vaadinapp-00.01.00-thin/</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">$ find embedded-jetty/target/vaadinapp-00.01.00-thin -maxdepth<span class="m">2</span> -not -path<span class="s1">'*/lib/*'</span></span></span><span class="line"><span class="cl"> vaadinapp-00.01.00-thin/vaadinapp-00.01.00</span></span><span class="line"><span class="cl"> vaadinapp-00.01.00-thin/vaadinapp-00.01.00/VERSION</span></span><span class="line"><span class="cl"> vaadinapp-00.01.00-thin/vaadinapp-00.01.00/bin</span></span><span class="line"><span class="cl"> vaadinapp-00.01.00-thin/vaadinapp-00.01.00/app</span></span><span class="line"><span class="cl"> vaadinapp-00.01.00-thin/vaadinapp-00.01.00/lib</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">$ ls -l …/bin/start.sh</span></span><span class="line"><span class="cl"> -rwxr-xr-x<span class="m">2608</span> start.sh</span></span></code></pre></div></div><p>The execute permission is preserved. The proof that the delivery is
complete is starting it from the unpacked 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>[main] INFO Serving static web resources from 5 classpath root(s)
[main] INFO Started oejs.Server@17bffc17{STARTING}[12.1.12,sto=0] @1709ms
[main] INFO Embedded Jetty serving http://127.0.0.1:8099/</code></pre></div><p>HTTP 200 on<code>/</code> and<code>/login</code>, five resource roots, 1,709 milliseconds until
the server is up. The last value is the telling one: it is in the same order of
magnitude as the 1,523 ms from Part 3 and thus far above the signature of a
scan that did not run.</p><h3 id="the-same-shape-for-the-other-form">The Same Shape for the Other Form</h3><p>The<code>fatjar</code> profile uses a second descriptor,<code>fat.xml</code>, which is identical
except for two lines: it takes the shaded archive instead of the application
archive, and it has<strong>no<code>dependencySet</code></strong> — there is nothing to copy into<code>lib/</code>, because everything already sits in the one file.</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;files&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;file&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;source&gt;</span>${project.build.directory}/vaadinapp-${project.version}-fat.jar<span class="nt">&lt;/source&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;outputDirectory&gt;</span>app<span class="nt">&lt;/outputDirectory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;destName&gt;</span>vaadinapp.jar<span class="nt">&lt;/destName&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/file&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/files&gt;</span></span></span><span class="line"><span class="cl"><span class="c">&lt;!-- no dependencySet --&gt;</span></span></span></code></pre></div></div><p>The<code>fileSet</code> for<code>bin/</code> stays unchanged —<strong>the same start script, the same
file.</strong> With that, both forms share the same shape:</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/ vaadinapp-00.01.00/
├── VERSION ├── VERSION
├── bin/start.sh ├── bin/start.sh
├── app/vaadinapp.jar └── app/vaadinapp.jar
└── lib/ 127 archives</code></pre></div><p>This is not an end in itself. Because the script puts<code>app/*:lib/*</code> on the
classpath and a missing<code>lib/</code> resolves to nothing, the same file starts both
forms. The systemd unit therefore does not distinguish them, and switching from
one to the other is a symlink and a restart — which is what allows chapter 6
to measure them under equal conditions in the first place.</p><h3 id="two-profiles-one-module">Two Profiles, One Module</h3><p>Both packagings are profiles of the same module, for the reason from Part 4, chapter 1:</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/, and a .tgz, without lib/</span></span></span><span class="line"><span class="cl">mvn package<span class="c1"># just the application archive</span></span></span></code></pre></div></div><p><code>-Pproduction</code> is required in both cases. Without this profile,<code>build-frontend</code> does not run, the production bundle is missing, and the bootstrap
aborts with its own check — instead of offering a server that serves an empty
page.</p><p>The third invocation without a profile is not an oversight. It produces an
application archive for development in seconds, without copying 127 files or
writing a 28 MB archive. Whoever builds the full deliverable on every
development cycle gets used to waiting times nobody needs.</p><h2 id="delivering-a-thin-distribution">Delivering a Thin Distribution</h2><p>Delivering a directory is more cumbersome than delivering a file.
This chapter shows what the complications are and which of them can be
resolved.</p><h3 id="restructuring-the-target-directory">Restructuring the Target Directory</h3><p>After Part 3, the reference server carries a flat layout:<code>app.jar</code> and<code>lib/</code>
directly under<code>/opt/vaadinapp</code>. Before releases can sit side by side,
this has to become the structure from chapter 4:</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 install -d -m<span class="m">750</span> -o root -g vaadinapp /opt/vaadinapp/releases</span></span></code></pre></div></div><p>The existing state is not converted into a release. It comes from
a delivery without<code>bin/</code> and without<code>VERSION</code> and could not be started with
the new<code>ExecStart</code>. It stays in place as the last path of retreat until the
first release runs, and is removed afterwards.</p><h3 id="the-transfer">The Transfer</h3><p>Two routes are available, and they differ in more than
notation.</p><p><strong>As an archive:</strong></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-thin.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)</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-thin.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 find /opt/vaadinapp/releases/$STAMP -type f -exec chmod 640 {} +</span></span></span><span class="line"><span class="cl"><span class="s1"> sudo chmod 750 /opt/vaadinapp/releases/$STAMP/bin/start.sh'</span></span></span></code></pre></div></div><p><strong>With<code>rsync</code>:</strong></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">rsync -a --delete<span class="se">\</span></span></span><span class="line"><span class="cl"> embedded-jetty/target/vaadinapp-00.01.00-thin/vaadinapp-00.01.00/<span class="se">\</span></span></span><span class="line"><span class="cl"> sven@demo.svenruppert.com:/tmp/release/</span></span></code></pre></div></div><p>The archive transfers everything every time.<code>rsync</code> transfers what has
changed — and for this form, that is the essential point, which chapter 6
quantifies in bytes. For the<strong>first</strong> release this makes no difference; for
every subsequent one it does.</p><p>In return, the archive route has a property<code>rsync</code> does not: the
transfer is one file and therefore atomic. What comes into being on the server
comes into being only on unpacking, and that happens alongside the running
operation. This combination — transfer an archive, operate a directory — is the
third option from chapter 7.</p><h3 id="two-pitfalls-part-3-learned-the-hard-way">Two Pitfalls Part 3 Learned the Hard Way</h3><p><strong>AppleDouble files.</strong> macOS&rsquo;s<code>tar</code> creates companion files following the
pattern<code>._name</code> for files with extended attributes and takes them along. Eleven
of them ended up in<code>lib/</code> in Part 3, and Java&rsquo;s classpath wildcard picks up every
file with the<code>.jar</code> extension — including these, which are not valid archives. The
first start ended with HTTP 503.</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="nv">COPYFILE_DISABLE</span><span class="o">=</span><span class="m">1</span> tar czf paket.tgz app lib bin</span></span></code></pre></div></div><p>With delivery via the<code>maven-assembly-plugin</code>, this no longer arises:
the archive is created inside the JVM from the files of the build directory, not via
macOS&rsquo;s<code>tar</code>. The pitfall concerns manual operation, and that does occur when
individual files are updated by hand.</p><p><strong>The permissions on the start script.</strong> A<code>zip</code> would lose the execute permission; a<code>chown -R</code> followed by a blanket<code>chmod</code> would as well. That is why the
commands above contain a dedicated line for<code>bin/start.sh</code> — and why chapter 2
builds the deliverable as<code>tar.gz</code> with an explicit<code>&lt;fileMode&gt;0755&lt;/fileMode&gt;</code>.</p><h3 id="the-smoke-test">The Smoke Test</h3><p>Before the symlink is repointed, the new release can be checked without touching
the running operation:</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 -u vaadinapp<span class="se">\</span></span></span><span class="line"><span class="cl"><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"><span class="nv">JAVA_OPTS</span><span class="o">=</span><span class="s1">'-Dapp.port=8099 -Dapp.storage.dir=/tmp/probe'</span><span class="se">\</span></span></span><span class="line"><span class="cl"> /opt/vaadinapp/releases/<span class="nv">$STAMP</span>/bin/start.sh</span></span></code></pre></div></div><p>A second port, a different data directory, the same service user. Two
indications from the log decide:</p><ul><li><code>Serving static web resources from 5 classpath root(s)</code> — the resources are
complete,</li><li>a startup time in the range of seconds — the annotation scan took place.</li></ul><p>A startup time in the double-digit millisecond range means the opposite,
regardless of whether the server responds. This signature has now appeared
twice: in Part 3 through an incomplete classpath, in
Part 4, chapter 5 through a destroyed service provider directory.</p><p>Only once this check passes does the switch from chapter 4 follow.</p><h2 id="releases-and-the-current-symlink">Releases and the<code>current</code> Symlink</h2><p>Part 1 explicitly decided against the symlink for updates:
releases are archived, the WAR is copied. Alongside that stood the sentence that the
symlink approach would come in Part 4, &ldquo;where it unfolds its purpose with the
directory distribution.&rdquo; This chapter redeems that promise — and begins with the
question of why it was<strong>not</strong> worthwhile back then.</p><h3 id="why-the-symlink-was-mere-decoration-for-the-war">Why the Symlink Was Mere Decoration for the WAR</h3><p>A WAR is a file.<code>mv</code> replaces a file atomically: either the old
directory entry points to the old content or the new one to the new; there is no
state in between. A symlink would merely have wrapped the same atomicity a
second time.</p><p>A Thin Distribution consists of 128 files.<strong>There is no way to replace 128
files in a single step.</strong> An<code>rsync</code> over the live tree leaves behind,
for the duration of the transfer, a classpath in which some
archives are new and others old. Whether that goes well depends on whether,
during that window, a class is loaded that exists in both versions.</p><p>This is where the symlink earns its place: it<strong>relocates the atomicity from the
files to the name.</strong><code>ln -sfn</code> rewrites a single directory entry
— all or nothing.</p><h3 id="the-directory-layout">The Directory Layout</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>/opt/vaadinapp/
├── current -&gt; releases/2026-09-09-1200
├── releases/
│ ├── 2026-09-08-1730/{VERSION,bin,app,lib}
│ └── 2026-09-09-1200/{VERSION,bin,app,lib}
└── logs/</code></pre></div><p><strong>Timestamp instead of version number.</strong> The directories are named after the
moment of delivery, not after the version. They therefore sort themselves and
remain unambiguous when the same version is delivered twice —
which happens regularly when a configuration is updated. Which version a
release contains is stated in its<code>VERSION</code> file.</p><p><strong><code>logs/</code> and the data live outside.</strong> Logs and<code>/var/lib/vaadinapp/data</code> outlive the release switch. If the data directory
sat under<code>current</code>, a rollback would lose the data — a routine move
would turn into an incident.</p><p><strong>Permissions as with the WAR:</strong><code>root:vaadinapp</code>, mode<code>750</code>. The service may read,
not write. A compromised process can thus neither modify its current
release nor write itself a next one.</p><h3 id="the-switch">The Switch</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"><span class="nv">STAMP</span><span class="o">=</span><span class="k">$(</span>date -u +%Y-%m-%d-%H%M<span class="k">)</span></span></span><span class="line"><span class="cl">sudo install -d -m<span class="m">750</span> -o root -g vaadinapp /opt/vaadinapp/releases/<span class="nv">$STAMP</span></span></span><span class="line"><span class="cl">sudo tar -xzf /tmp/vaadinapp-00.01.00-thin.tar.gz<span class="se">\</span></span></span><span class="line"><span class="cl"> -C /opt/vaadinapp/releases/<span class="nv">$STAMP</span> --strip-components<span class="o">=</span><span class="m">1</span></span></span><span class="line"><span class="cl">sudo chown -R root:vaadinapp /opt/vaadinapp/releases/<span class="nv">$STAMP</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">sudo ln -sfn releases/<span class="nv">$STAMP</span> /opt/vaadinapp/current</span></span><span class="line"><span class="cl">sudo systemctl restart vaadinapp</span></span></code></pre></div></div><p>Six lines, two of which constitute the actual switch. Everything before that
happens<strong>alongside</strong> the running operation: the new release is laid down in
full while the application keeps running from the old one.</p><p><code>ln -sfn</code> with both switches is no ornament here. Without<code>-n</code>,<code>ln</code> follows the
existing symlink and creates the new one<strong>inside</strong> the target directory —
the result is<code>releases/2026-09-08-1730/2026-09-09-1200</code>, and<code>current</code> still
points to the old release. The command reports no error.</p><h3 id="what-happens-to-the-running-process-when-the-symlink-is-repointed-nothing">What Happens to the Running Process When the Symlink Is Repointed: Nothing</h3><p>This is the property the whole procedure rests on, and it can be
demonstrated. Two releases side by side,<code>current</code> points to the first, the
application is running. Then the symlink is repointed,<strong>without</strong> a restart:</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 current
current -&gt; releases/2026-09-09-1200
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8095/
200
$ ln -sfn releases/2026-09-09-1400 current
$ ls -l current
current -&gt; releases/2026-09-09-1400
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8095/
200
$ lsof -p 71775 | grep -c 2026-09-09-1200
139
$ lsof -p 71775 | grep -c 2026-09-09-1400
0</code></pre></div><p>The process keeps serving requests and holds<strong>139 open files in the old
release</strong>, not a single one in the new. The reason lies below Java: a path
is resolved on open, not on every access. Whatever happens to the name
afterwards no longer concerns the open descriptor.</p><p>Two things follow from this at once:</p><ul><li><strong>The switch needs a restart.</strong> Without one, the old version would keep
running indefinitely while the symlink claims otherwise.</li><li><strong>The rollback is safe.</strong> The old release sits untouched on
disk — not as an archive that would first have to be unpacked, but
ready to start.</li></ul><h3 id="cleanup">Cleanup</h3><p>At roughly 30 MB per state,<code>releases/</code> grows faster than one notices. The
last five are enough:</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 -1d /opt/vaadinapp/releases/*/<span class="p">|</span> sort<span class="p">|</span> head -n -5<span class="p">|</span><span class="se">\</span></span></span><span class="line"><span class="cl"> xargs -r sudo rm -rf</span></span></code></pre></div></div><p>This line belongs at the<strong>end</strong> of the rollout, not at its beginning: first
the new release is proven to run, then old ones are cleared away.
The other way around, you would be left without a rollback target in case of
failure.</p><h2 id="rollback">Rollback</h2><p>The rollback is the reason the procedure of the previous chapter is
operated at all. It consists of two lines:</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 ln -sfn releases/2026-09-08-1730 /opt/vaadinapp/current</span></span><span class="line"><span class="cl">sudo systemctl restart vaadinapp</span></span></code></pre></div></div><p>This is the same motion as the switch, just with a different target. There is
no separate procedure for the failure case, no unpacking from an archive, no
restoring from a backup.</p><p>Measured on the reference server — from repointing the symlink to the first
request answered again:<strong>4,938 milliseconds.</strong> That is the same order of
magnitude as an ordinary restart (4,653 ms on average), because nothing else
takes place. The duration does not depend on how large the application is or
how far back the rollback reaches.</p><h3 id="why-that-is-enough">Why That Is Enough</h3><p>Because the old release was never touched. Chapter 4 showed that a
running process holds its files through open descriptors and notices nothing
when the symlink is repointed — 139 open files in the old release, none in the
new. The flip side of the same property is that the old release
remains<strong>ready to start</strong> as long as it is not cleared away.</p><p>The time to recovery therefore does not depend on the size of the application,
only on the duration of a restart. That is the real
gain over the procedure from Part 1, where a WAR had to be copied back from
the archive.</p><h3 id="what-the-rollback-does-not-take-back">What the Rollback Does Not Take Back</h3><p>Three things, and all three need to be considered beforehand.</p><p><strong>The sessions.</strong> They live in memory, as they have since Part 2. A restart
discards them, and logged-in users land on the login page — on a switch
as on a rollback. This is not a property of this procedure but a
consequence of how sessions are held, and it could only be avoided with
persistent sessions.</p><p><strong>The data.</strong><code>/var/lib/vaadinapp/data</code> lives outside the releases and is not
reset. That is intentional: a rollback is meant to take back the application,
not the users&rsquo; work. It does mean, however, that a release
that changed the data — a migration — cannot be taken back
this way. The symlink makes the code reversible, not the schema.</p><p><strong>The unit.</strong> It lives under<code>/etc/systemd/system</code> and belongs to the machine.
This is exactly why the cut from Part 4, chapter 9 is placed where it is: because
the release&rsquo;s JVM options live inside the release, the rollback brings them along.
If they lived in the unit, someone would have to remember to take them back as well —
and would forget in the heat of a failure.</p><h3 id="the-procedure-when-it-gets-serious">The Procedure When It Gets Serious</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"><span class="c1"># 1. Which releases are available?</span></span></span><span class="line"><span class="cl">ls -1 /opt/vaadinapp/releases/</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 2. What does current point to right now?</span></span></span><span class="line"><span class="cl">readlink /opt/vaadinapp/current</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 3. Which version is in the target?</span></span></span><span class="line"><span class="cl">cat /opt/vaadinapp/releases/2026-09-08-1730/VERSION</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 4. Switch back and restart</span></span></span><span class="line"><span class="cl">sudo ln -sfn releases/2026-09-08-1730 /opt/vaadinapp/current</span></span><span class="line"><span class="cl">sudo systemctl restart vaadinapp</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 5. Verify, do not assume</span></span></span><span class="line"><span class="cl">systemctl is-active vaadinapp</span></span><span class="line"><span class="cl">curl -s -o /dev/null -w<span class="s1">'%{http_code}\n'</span> https://demo.svenruppert.com/</span></span><span class="line"><span class="cl">journalctl -u vaadinapp -n<span class="m">20</span> --no-pager</span></span></code></pre></div></div><p>Step 3 is the one that gets skipped, and the one that justifies the<code>VERSION</code> file from
Part 4, chapter 7. The directory names are timestamps; which of them contains the
version to roll back to, only this file can say.</p><p>Step 5 is the one that runs through the whole series: the symlink can point
correctly and the service still fail to run, for instance because the old
release has a start script that expects an environment variable removed in the
meantime. A rollback is proven only when a request is answered.</p><h2 id="the-measured-comparison">The Measured Comparison</h2><p>Unlike in Parts 2 and 3, it is not the server that changes here. Both forms
load the same classes from the same archives with the same bootstrap. A
table in which half the rows show identical numbers proves nothing —
which is why there are five rows, and two of them have not appeared in this
series before.</p><p>Measurements were taken on the reference server, as in all previous parts: Debian 13,
Temurin 26, the same unit, the same hardening. Both forms sit side by side as
releases; switching between them is a symlink and a restart —
only that makes the numbers comparable.</p><p>The hypotheses were written down<strong>before</strong> the measurement. Two of them were
not confirmed, and both times the opposite is the more instructive
result.</p><h3 id="delivery-size">Delivery Size</h3><p>Both forms are delivered as releases and transferred as<code>.tgz</code> for that purpose —
same structure, same transport form, so the numbers are comparable.</p><table><thead><tr><th>Form</th><th style="text-align: right">Size</th></tr></thead><tbody><tr><td>Fat distribution as<code>.tgz</code></td><td style="text-align: right"><strong>25,807,851 Bytes</strong></td></tr><tr><td>Thin distribution as<code>.tgz</code></td><td style="text-align: right">26,676,831 Bytes</td></tr><tr><td><em>raw fat JAR, unpackaged</em></td><td style="text-align: right"><em>28,890,183 Bytes</em></td></tr><tr><td><em>Thin distribution unpacked</em></td><td style="text-align: right"><em>28.4 MB in 128 files</em></td></tr></tbody></table><p><em>The hypothesis was: the fat JAR is somewhat smaller, because 127 archives each
carry their own central directory.</em><strong>Confirmed</strong> — the fat form is smaller by
868,980 bytes, or 3.3 percent.</p><p>The third row deserves a warning, because it invites a fallacy.
Compare the<strong>raw</strong> fat JAR with the<code>.tgz</code> of the Thin Distribution and the
result reverses: 28.89 versus 26.68 MB. But that is not a comparison of two
delivery forms; it is a comparison of once-compressed and twice-compressed data.
A JAR compresses each entry on its own;<code>gzip</code> over the<code>tar</code> stream
compresses once more on top and exploits repetitions across
file boundaries. Whoever measures this way measures the packaging of the packaging.</p><p>The amount is small in both directions. For the choice between the forms,
size yields nothing — it is listed here mainly because the
obvious mismeasurement leads so easily to a false statement.</p><h3 id="startup-time">Startup Time</h3><p>Three runs per form on the reference server, Jetty&rsquo;s own timestamp up to<code>Started Server</code>, plus the downtime from<code>restart</code> to the first
answered request:</p><table><thead><tr><th/><th style="text-align: right">Thin Distribution</th><th style="text-align: right">Fat JAR</th></tr></thead><tbody><tr><td>Uptime, individual runs</td><td style="text-align: right">3,621 / 3,563 / 3,538 ms</td><td style="text-align: right">3,641 / 3,319 / 3,487 ms</td></tr><tr><td><strong>Uptime, mean</strong></td><td style="text-align: right"><strong>3,574 ms</strong></td><td style="text-align: right"><strong>3,482 ms</strong></td></tr><tr><td>Downtime, individual runs</td><td style="text-align: right">4,747 / 4,671 / 4,540 ms</td><td style="text-align: right">4,754 / 4,365 / 4,618 ms</td></tr><tr><td><strong>Downtime, mean</strong></td><td style="text-align: right"><strong>4,653 ms</strong></td><td style="text-align: right"><strong>4,579 ms</strong></td></tr></tbody></table><p><em>The hypothesis was: the fat JAR starts faster, because Jetty&rsquo;s<code>MetaInfConfiguration</code>
opens one archive instead of 127.</em><strong>Not confirmed.</strong> The difference is 92
milliseconds against a spread of 83 and 322 milliseconds, respectively,
within the runs — the ranges overlap completely. The same
measurement on the development machine yielded 1,654 versus 1,671 milliseconds, that is,
the opposite direction at an equally small amount.</p><p>That is the more instructive finding. It says<strong>where the time actually
goes</strong>: not into opening the archives, but into the ASM pass over
the class files inside them. Their number is the same in both forms. Creating 127
file handles carries no weight next to that.</p><p>This also puts the number from Part 3 into perspective. The 139 milliseconds versus
1,523 milliseconds there were<strong>not</strong> the price of 127 opened archives,
but the difference between a scan that happens and one that does not.
Whoever derives from it the expectation that fewer archives would bring a
faster start has misread the measurement — and this chapter is the
cross-check on that.</p><h3 id="memory-footprint">Memory Footprint</h3><p>This is where the most definite hypothesis stood, and it is the second one to fall.</p><table><thead><tr><th/><th style="text-align: right">Thin Distribution</th><th style="text-align: right">Fat JAR</th></tr></thead><tbody><tr><td><code>MemoryCurrent</code>, individual runs</td><td style="text-align: right">358 / 353 / 362 MiB</td><td style="text-align: right">407 / 403 / 408 MiB</td></tr><tr><td><strong><code>MemoryCurrent</code>, mean</strong></td><td style="text-align: right"><strong>358 MiB</strong></td><td style="text-align: right"><strong>406 MiB</strong></td></tr><tr><td>RSS</td><td style="text-align: right">382 MiB</td><td style="text-align: right">430 MiB</td></tr><tr><td>Threads</td><td style="text-align: right">31</td><td style="text-align: right">31</td></tr></tbody></table><p><em>The hypothesis was: no difference — same classes, same bootstrap.</em><strong>Refuted.</strong> The fat JAR needs<strong>48 MiB more</strong>, which is 13 percent. The
values per form sit close together (358 to 362 versus 403 to 408); the
ranges do not touch. That is not noise.</p><p>For context: Part 3 measured 336 MiB for the flat layout; the
Thin Distribution as a release, at 358 MiB, is in the same order of magnitude.</p><h4 id="where-the-48-mib-come-from--and-where-they-do-not">Where the 48 MiB Come From — and Where They Do Not</h4><p>The obvious explanation would be that a 28 MB archive is mapped into memory
as a whole. A look at<code>/proc/&lt;pid&gt;/maps</code> refutes that:</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 Fat
file-backed mappings
on .jar files 0 0
mappings total 332 331
open archive descriptors 139 4</code></pre></div><p><strong>Not a single archive is memory-mapped</strong> — in neither form. The JVM
reads the archives&rsquo; directories into ordinary memory; it does not map the
files.</p><p><code>/proc/&lt;pid&gt;/smaps_rollup</code> shows where the difference sits instead:</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 Fat
Rss 384 MiB 436 MiB
Private_Dirty 358 MiB 410 MiB ← the entire difference
Private_Clean 23 MiB 23 MiB
Shared_Clean 2 MiB 2 MiB</code></pre></div><p>The increase sits<strong>entirely in anonymous, dirty memory</strong> — heap
or natively requested JVM memory, not in file mappings and not
in the page cache.</p><p><strong>This narrows the cause down, but does not determine it.</strong> What it is not is
stated above; what it is, this measurement does not yield. The number was
taken ten seconds after startup in each case, that is, before garbage collection
has settled — a larger retained set immediately after the scan
is conceivable and would have to be examined separately.</p><p>What the finding says<strong>for certain</strong>: the statement from Part 3 — that what is
measured is not the number of bytes shipped but the number of loaded classes —
falls short. Here the classes are the same, and the footprint differs
anyway. The form of delivery is not without consequence for memory.</p><h3 id="change-volume-for-a-patch-release">Change Volume for a Patch Release</h3><p>The first of the two new rows — and the only one with a clear
difference.</p><p>Of the 127 archives in<code>lib/</code>,<strong>126 are byte-identical</strong> to the files in the
local Maven repository. They come from there unchanged and do not change
between two builds. Only the project&rsquo;s own archives are
rewritten.</p><p>So if only the application code changes:</p><table><thead><tr><th>Form</th><th style="text-align: right">To transfer</th></tr></thead><tbody><tr><td>Thin Distribution</td><td style="text-align: right"><code>app/vaadinapp.jar</code> (10,686 B) +<code>lib/vaadinapp-core-…jar</code> (2,590,109 B) =<strong>2,600,795 B</strong></td></tr><tr><td>Fat JAR</td><td style="text-align: right">the entire archive =<strong>28,890,183 B</strong></td></tr><tr><td/><td style="text-align: right"><strong>Ratio 11.1 to 1</strong></td></tr></tbody></table><p><code>rsync</code> transfers even less in both cases, because it matches blocks within
changed files. That changes nothing about the ratio: with the fat JAR,<strong>every</strong> change is a change to the same 28 MB file.</p><p><em>A note on this application:</em> the 2.59 MB is the<code>core</code> module, which contains the
entire application code and the Vaadin production bundle. In a
project without this separation, the application archive would stand here alone — ten
kilobytes. The ratio would then not be 11 to 1 but 2,700 to 1.</p><h3 id="diagnostic-effort">Diagnostic Effort</h3><p>The second new row, without a number, but with a demonstrable difference.</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"># Thin Distribution</span></span></span><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><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Fat JAR</span></span></span><span class="line"><span class="cl">$ unzip -l /opt/vaadinapp/current/app.jar<span class="p">|</span> grep -i bouncycastle</span></span><span class="line"><span class="cl"><span class="o">(</span>nothing — the classes live under org/bouncycastle/, the version nowhere<span class="o">)</span></span></span></code></pre></div></div><p>The difference is not that the second question is harder to answer.
It<strong>cannot be answered</strong>. The classes are there; the provenance is gone.
The only substitute is the SBOM from build time — which for the fat JAR is thus
not an accessory but the only remaining source.</p><h3 id="what-the-measurement-does-not-yield">What the Measurement Does Not Yield</h3><p><strong>Threads</strong> were collected (53 versus 49) and are not reported: there was
no hypothesis stated in advance, and without one, a deviation
between two runs is not a finding but noise.</p><p><strong>Build time</strong> differs — Shade writes a 28 MB archive,<code>copy-dependencies</code> copies files. It is a property of the
development machine, not of the delivery, and this series measures at the server.</p><h3 id="summary">Summary</h3><table><thead><tr><th>Metric</th><th>Thin Distribution</th><th>Fat JAR</th></tr></thead><tbody><tr><td>Delivery as<code>.tgz</code></td><td>26.68 MB</td><td><strong>25.81 MB</strong></td></tr><tr><td>Startup time (server)</td><td>3,574 ms</td><td>3,482 ms — no difference</td></tr><tr><td>Memory (<code>MemoryCurrent</code>)</td><td><strong>358 MiB</strong></td><td>406 MiB</td></tr><tr><td>Patch release</td><td>2.60 MB</td><td>28.89 MB</td></tr><tr><td>&ldquo;Which version is deployed?&rdquo;</td><td><code>ls</code></td><td>not answerable</td></tr></tbody></table><p>Size goes narrowly to the fat JAR, startup time to no one,
diagnostic effort and the patch release clearly to the Thin Distribution. None
of these rows decides the choice on its own — and the fat JAR&rsquo;s real strength
appears in none of them, because it cannot be captured in a number.
Chapter 7 draws the conclusion.</p><h2 id="recommendation">Recommendation</h2><p>Both forms run. Both deliver HTTP 200. Startup time does not differ;
delivery size differs by three percent in the fat JAR&rsquo;s favor. Up to that point,
a measurement would yield little for a recommendation.</p><p>Two rows from chapter 6 do after all, and both point in the same
direction: the fat JAR needs<strong>48 MiB more memory</strong> (406 versus 358
MiB), and a patch release transfers<strong>eleven times as many bytes</strong> with it. Add
to that what cannot be measured but affects every day of operation: with the
Thin Distribution, the question &ldquo;which version is deployed?&rdquo; is answered with an<code>ls</code>;
with the fat JAR, not at all.</p><h3 id="the-recommendation-depends-on-the-operational-situation">The Recommendation Depends on the Operational Situation</h3><table><thead><tr><th>Situation</th><th>Form</th><th>Reason</th></tr></thead><tbody><tr><td>A server you manage yourself — the situation of this series</td><td><strong>Thin Distribution</strong></td><td>The classpath is visible, a patch transfers one eleventh of the bytes, the service needs 48 MiB less, and &ldquo;which version is deployed?&rdquo; is answered with<code>ls</code>.</td></tr><tr><td>Handing off to someone who does not know the machine</td><td><strong>Fat JAR</strong></td><td>One file, one command, no structure that can fall apart while being copied.</td></tr><tr><td>Container image</td><td><strong>Fat JAR</strong></td><td>The image is immutable anyway. Partial updates and inspecting<code>lib/</code> lead nowhere there.</td></tr><tr><td>CI intermediate build, test environment</td><td><strong>Fat JAR</strong></td><td>One artifact that can be archived and restored as a whole.</td></tr></tbody></table><p>The sentence it comes down to:</p><div class="pull-quote"><p><strong>A fat JAR hides its components; a Thin Distribution shows them.
Whoever manages the machine themselves needs the visibility; whoever merely
ships does not.</strong></p></div><h3 id="the-third-option">The Third Option</h3><p>The juxtaposition &ldquo;one archive or many files?&rdquo; contains an assumption that
does not hold: that the answer for<strong>transferring</strong> and for<strong>running</strong>
would have to be the same. It does not.</p><p>On the reference server, both forms are transferred as<code>.tgz</code> and unpacked into a
release directory. The transfer is thus<strong>one file</strong> in both cases —
atomic, verifiable with a checksum, without the danger of 128
files arriving halfway. What sits in the directory afterwards is independent of that.</p><p>Both releases share the same shape:</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/ vaadinapp-00.01.00/
├── VERSION ├── VERSION
├── bin/start.sh ├── bin/start.sh ← same file
├── app/vaadinapp.jar └── app/vaadinapp.jar (28,89 MB)
└── lib/ 127 archives</code></pre></div><p>The start script is<strong>byte-for-byte identical</strong>. It puts<code>"$APP_HOME/app/*:$APP_HOME/lib/*"</code> on the classpath; if<code>lib/</code> is missing, the
second half resolves to nothing, and the one complete archive in<code>app/</code> is all
the annotation scan needs — exactly the property from Part 4, chapter 4, that
the fat JAR rests on.</p><p><strong>The consequence is practical:</strong> the systemd unit does not distinguish the two
forms. Switching from one to the other is a symlink and a restart, that is,
the same operation as any release switch. That is the reason the
two forms could be measured under equal conditions in chapter 6 at all.</p><p>What this third option is<strong>not</strong>: a size advantage. As<code>.tgz</code>, both sit
at roughly 25 to 26 MB, and the fat form is even the slightly
smaller one. The gain lies solely in the fact that the question of atomicity
in transfer and the question of visibility in operation can be answered
separately.</p><h3 id="what-does-not-work-as-a-justification-here">What Does Not Work as a Justification Here</h3><p><strong>&ldquo;Modern&rdquo; or &ldquo;best practice.&rdquo;</strong> The fat JAR is, thanks to Spring Boot, the
more common route. That does not make it the better one — it makes it the
expected one, and that is an argument about habits, not about technology.</p><p><strong>&ldquo;The proper Unix way.&rdquo;</strong> The opposite direction is just as unfit.<code>bin/</code>,<code>lib/</code>, and a start script are an old and proven form, but their lineage
proves nothing about their suitability for this case.</p><p>Both forms are old, both are in use, and the choice follows from the situation,
not from fashion.</p><h3 id="what-runs-on-the-reference-server">What Runs on the Reference Server</h3><p>The Thin Distribution, transferred as<code>.tgz</code> and unpacked into a release
directory — that is, the third route. This is not hedging in all directions,
but the consequence of the same reasoning: the machine is managed in-house, so
visibility into its components counts. And the transfer should still be
atomic.</p><p>The state after this part:</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/ seven releases, from the WAR archives of part 1
up to both forms of this part</code></pre></div><p>Both forms remain in place as releases. Switching between them is thus, at any
time, a symlink and a restart — measured: 4,938 milliseconds.</p><p>That Part 6 builds on this is a bonus, but decides nothing. A
recommendation that follows from the next part instead of from the matter at
hand would be none.</p><h2 id="outlook-on-part-6">Outlook on Part 6</h2><p>Parts 4 and 5 have not moved any boundary. They packaged and
delivered what Part 3 built, and in doing so answered two questions that
had been open since then: what the delivery looks like, and how a release
is switched and taken back.</p><p>What the application still expects from the target system has thereby melted
down to a single thing: a Java runtime, installed and maintained by
someone else. On the reference server that is a Temurin 26, set up via SDKMAN under
the service user, reachable through a symlink — the same mechanism
Part 1 used for Jetty, and which has since been dropped
there.</p><p><strong>Part 6 takes the runtime along as well.</strong><code>jlink</code> builds, from the modules
of the JDK, an image that contains only what this application calls, and places
it next to the distribution.<code>ExecStart=…/current/bin/start.sh</code> remains the same
line — only<code>JAVA_HOME</code> then points into the release instead of at the machine. The
server no longer needs an installed Java after that.</p><p>Two things need to be said up front so they do not appear as
surprises.<strong><code>jlink</code> does not make the application modular:</strong> it builds an image
of the platform modules; the application code stays on the classpath. The
recommendation from chapter 7 is untouched by this — whoever chose the fat JAR
gets ahead just the same. And<strong><code>jdeps</code> will not deliver the list of required modules
completely:</strong> Vaadin loads via reflection, Jetty via<code>ServiceLoader</code>,
and static analysis does not see that.</p><p>This is the same finding that holds this part and the previous one together, for
the third time:<strong>what is decided at build time about runtime must go beyond
what the compiler sees.</strong> In Part 3 it was the classpath
that fell short. In Part 4, chapter 5 it was the service provider directory that a
merge without knowledge of its contents would have destroyed. In Part 6 it will
be the module list.</p>
]]></content:encoded><category>Java</category><category>Vaadin</category><media:content url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-5-deliver-switch-compare-hero.png" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-5-deliver-switch-compare-hero.png"/><enclosure url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-5-deliver-switch-compare-hero.png" type="image/jpeg" length="0"/></item></channel></rss>