<?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>Deployment on Sven Ruppert</title><link>https://svenruppert.com/tags/deployment/</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/deployment/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/deployment/</link></image><lastBuildDate>Thu, 24 Sep 2026 10:00:00 +0200</lastBuildDate><item><title>Vaadin Deployment on Hetzner — Part 1: From Vaadin Project to systemd Service</title><link>https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-1-from-vaadin-project-to-systemd-service/</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-1-from-vaadin-project-to-systemd-service/</guid><description>From a Vaadin production build to a hardened systemd service on Debian: unit files, sandboxing directives, and an exposure score dropping from 9.2 to 1.1.</description><content:encoded>&lt;![CDATA[<p>Reference article in the series<em>Vaadin – Deployment on Hetzner</em>. From the production build to a hardened systemd service: Jetty, WAR, and Java on a Debian 13 server — with no outside access yet. All commands, outputs, and measurements come from an actual run.</p><h2 id="target-architecture">Target Architecture</h2><p>This part assumes the server that Part 0 produces: Debian 13, an administrative
user with<code>sudo</code>, SSH exclusively via key, a firewall that lets only 22, 80, and
443 through inbound, a service account without login capability, and a directory
structure that separates program, configuration, and mutable data. Anyone who
set up their server differently should know the deviations — the following steps
build on this foundation.</p><p>From here on, the application joins the picture.</p><h3 id="three-building-blocks">Three Building Blocks</h3><figure><img src="/images/2026/09/vaadin-deployment-on-hetzner-part-1-from-vaadin-project-to-systemd-service-teil01-zielarchitektur.png" alt="The WAR as a hardened systemd service on the loopback address" loading="lazy" decoding="async"/><p><em>Figure 1: The state at the end of this part: hardened and reachable only locally.</em></p><p>At the end of this part, a Vaadin application runs as its own system service on
the server — started at boot, locked down, logged. It is reachable exclusively
locally. Three building blocks are involved:</p><p><strong>Jetty</strong> is the servlet container. It is installed as standalone software, runs
under its own service account, and listens exclusively on the loopback address.
It cannot be reached from the internet.</p><p><strong>The WAR</strong> is the application. It is built on the development machine, copied to
the server, and served by Jetty. Nothing more belongs to the application in this
part — not Jetty, not the Java runtime, and certainly not the server.</p><p><strong>systemd</strong> keeps the service running, starts it at boot, locks it down, and
collects the logs.</p><p>The fourth building block is deliberately missing here:<strong>Caddy</strong>, which accepts
requests from the internet, terminates TLS, and obtains the certificate. It is
the topic of Part 2. This order is no accident — and no convenience either.</p><h3 id="why-outside-access-comes-only-afterward">Why Outside Access Comes Only Afterward</h3><p>A service that is not yet reachable can be set up in peace. It can be started,
observed, restarted, and stopped again without anyone watching. Part 0 showed
how fast that otherwise happens: the first foreign login attempt arrived 23
seconds after system startup.</p><p>That is why this part ends at a point where you can stop. Anyone who takes a
break after the last chapter leaves behind not a half-finished state on the
network but a finished service that nobody sees from outside. The way out is
built deliberately in Part 2 — with certificate, forwarding headers, and
everything that goes with it.</p><h3 id="the-boundary-that-shifts">The Boundary That Shifts</h3><p>The section about the WAR contains the common thread of the whole series. In
this part, the application is only the WAR; everything else is environment that
somebody set up beforehand and that is maintained independently of it.</p><p>In the later parts, this boundary moves outward. Part 3 turns Jetty into a
library of the application. Parts 4 and 5 change the form of delivery. Part 6
finally takes the Java runtime in as well — then the application runs on a
server with no Java installed at all.</p><p>Each of these shifts takes responsibility away from the server and hands it to
the release. Which split is the right one depends on who maintains the server
and how often the application is delivered. This part describes the starting
point the later ones compete against.</p><h3 id="what-becomes-visible-along-the-way">What Becomes Visible Along the Way</h3><p>The setup is deliberately not the most convenient one. There is no start command
that does everything at once. Instead, each step is done individually: build the
artifact, install the runtime, set up the container, define the service, lock it
down.</p><p>That is more work than a ready-made container image — and it makes visible what
a container otherwise hides. Anyone who has once decided by hand which process
listens on which port under which account and which directory it may write to
will make those decisions more consciously later in a container environment as
well.</p><h2 id="ground-rules-of-the-series">Ground Rules of the Series</h2><p>This series leaves things out, and deliberately so. The omissions are not a
simplification for beginners but the core of the undertaking: the point is to
see the mechanics that usually sit behind an abstraction.</p><h3 id="the-code-state-for-this-part">The Code State for This Part</h3><p>The demo application is open. The repository evolves over the course of the
series — each part therefore has its own state that can be checked out:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">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-02</span></span></code></pre></div></div><p>The tag<code>teil-02</code> carries the state that this part and Part 2 describe:<strong>a
single Maven module</strong> whose build produces<code>target/ROOT.war</code>. All paths in the
following chapters refer to it.</p><p>From Part 3 on, the project is split into several modules, because a second
delivery form joins there. Anyone who clones the current main branch therefore
finds<code>war-jetty/target/ROOT.war</code> instead of<code>target/ROOT.war</code> — and a reason
for it that Part 3 explains.</p><h3 id="no-spring">No Spring</h3><p>The application is a pure Vaadin application without Spring Boot. This removes
the automation that would otherwise take care of the largest part of this
article: embedded server, configuration resolution, executable archive,
operational endpoints.</p><p>Spring Boot is a good choice for that. Anyone who uses it does not have to
decide much of what is described here — but they should know what was decided.</p><h3 id="no-development-mode">No Development Mode</h3><p>Everything that runs here is a production build. Vaadin behaves distinctly
differently in the two modes: in development mode, an additional process loads
the frontend at runtime, there is live reload and verbose error pages. None of
that has any business on a server.</p><p>Chapter 4 shows how to recognize a production build — and what happens if you
accidentally fail to build one.</p><h3 id="no-docker">No Docker</h3><p>No container, no image, no registry. The application runs as an ordinary
process on the operating system.</p><p>That is the more unusual variant today and therefore needs explaining: a
container answers several questions at once — isolation, dependencies,
delivery, restart behavior. These questions do not disappear when no container
is involved; they merely have to be answered one at a time. That is exactly
what happens in Chapters 9 through 11.</p><h3 id="no-kubernetes">No Kubernetes</h3><p>One server, one application. No orchestration, no replicas, no rollout
procedure with intermediate states.</p><p>That has consequences the text does not hide: a restart means downtime, and it
is measurable. Part 2, Chapter 8 gives the number.</p><h3 id="no-cicd">No CI/CD</h3><p>The artifact is built on the development machine and brought to the server by
hand. No pipeline, no build server, no automatic rollout.</p><p>That, too, is intentional. A pipeline automates a procedure — it does not
replace it. Anyone who has performed the procedure by hand once can automate
it; anyone who has never seen it automates a guess.</p><h3 id="jetty-as-the-servlet-container">Jetty as the Servlet Container</h3><p>Of the servlet containers Vaadin supports, the choice falls on Jetty. It is
lean, its configuration is easy to read, and in Part 3 it can be pulled into
the application as a library without a break — which would not be possible with
a full application server.</p><p>For Tomcat, almost everything described here applies analogously; the paths and
module names differ.</p><h3 id="manual-deployment">Manual Deployment</h3><p>Copy, stop the service, swap the file, start the service. Four steps that
appear individually in Part 2, Chapter 8 and in reverse in Part 2, Chapter 9.</p><p>Anyone who knows them will know exactly where an automated deployment can fail
— and what a rollback actually costs.</p><h2 id="versions-used">Versions Used</h2><table><thead><tr><th>Component</th><th>Version</th></tr></thead><tbody><tr><td>Debian</td><td>13 (trixie), kernel 6.12.107</td></tr><tr><td>JDK</td><td>Eclipse Temurin 26.0.2, installed via<a href="https://8g8.eu/2ysx43">SDKMAN</a></td></tr><tr><td>Vaadin</td><td>25.2.6</td></tr><tr><td>Jetty</td><td>12.1.12,<code>ee11</code> branch</td></tr><tr><td>Caddy</td><td>2.11.4</td></tr></tbody></table><p>These versions reflect the moment the text was written. They age while it is
being read. That changes nothing about the procedure — the commands stay the
same; only the numbers inside them shift. Anyone rebuilding this checks the
current versions and adjusts accordingly.</p><p>Four of the five entries deserve a justification, because they depend on one
another.</p><h3 id="vaadin-determines-the-rest">Vaadin Determines the Rest</h3><p>The chain starts with Vaadin, not with the server.<strong>Vaadin 25 requires Java 21
or newer, Jakarta EE 11, and the Servlet specification 6.1.</strong> That sets the
minimum requirements for everything else.</p><p>The predecessor line Vaadin 24 requires Java 17, Jakarta EE 10, and Servlet 6.0
— and thus leads to a different Jetty branch. Anyone migrating an existing
application has to settle this question first; everything else follows from it.</p><h3 id="jetty-121-not-120">Jetty 12.1, Not 12.0</h3><p>This is where the stumbling block sits that costs the most time if you miss it.</p><p>Jetty 12 supports several Jakarta EE generations side by side, each in its own
module branch:<code>ee8</code>,<code>ee9</code>,<code>ee10</code>,<code>ee11</code>. Vaadin 25 needs<code>ee11</code> — and<strong>that exists only from Jetty 12.1 on.</strong> The 12.0 line ends at<code>ee10</code>.</p><p>The obvious decision to take the seemingly more mature 12.0 therefore leads
into a dead end: the required modules are simply not there, and the error
message at startup is not very helpful. Chapter 7 shows how to check this in
advance.</p><h3 id="jdk-26-is-not-an-lts">JDK 26 Is Not an LTS</h3><p>Eclipse Temurin 26 is used. That is the newest version at the time of writing —
and explicitly<strong>not a long-term support release.</strong> Adoptium lists 8, 11, 17,
21, and 25 as LTS versions.</p><p>Anyone who runs the latest version opts for semiannual updates instead of
multi-year support. For an application in production, that is a conscious
decision, not a triviality.</p><p>It is easier to make here than elsewhere, because the JDK is installed via
SDKMAN, which shrinks a switch down to a symlink and a restart. Chapter 6
describes that. In Part 6, the same property becomes an argument: as soon as
the runtime is part of the release artifact, a Java update becomes an
application release.</p><h3 id="caddy-instead-of-nginx-or-apache">Caddy Instead of nginx or Apache</h3><p>Caddy handles certificate acquisition and renewal without additional software
and without a cron entry. The complete configuration for this purpose is two
lines; Part 2, Chapter 3 shows it.</p><p>The price is lower adoption: anyone working in an existing environment is more
likely to find nginx or Apache HTTPD there. For both, extensive guides exist
for Vaadin — for Caddy, they do not. That is exactly why it is used here.</p><p><strong>No claim to completeness for this table:</strong> Not listed are Maven, Node.js, and
the tools that handle the frontend build. They run exclusively on the
development machine and are irrelevant for the server — which Chapter 5 turns
into a topic of its own.</p><h2 id="from-vaadin-project-to-production-artifact">From Vaadin Project to Production Artifact</h2><p>The sample application is an ordinary Vaadin Flow application without Spring:
login, roles, an audit log, and persistent storage. It is packaged as a WAR.</p><h3 id="production-profile">Production Profile</h3><p>Without special instructions, Maven builds a development state. The difference
sits in a profile:</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;profile&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;id&gt;</span>production<span class="nt">&lt;/id&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;dependencies&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>com.vaadin<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>vaadin-core<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;exclusions&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;exclusion&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>com.vaadin<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>vaadin-dev<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/exclusion&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/exclusions&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/dependencies&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;build&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;plugins&gt;</span></span></span><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>com.vaadin<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>vaadin-maven-plugin<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;version&gt;</span>${vaadin.version}<span class="nt">&lt;/version&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;goals&gt;&lt;goal&gt;</span>build-frontend<span class="nt">&lt;/goal&gt;&lt;/goals&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;phase&gt;</span>compile<span class="nt">&lt;/phase&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><span class="line"><span class="cl"><span class="nt">&lt;/plugins&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/build&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/profile&gt;</span></span></span></code></pre></div></div><p>Two things happen here, and both are essential.</p><p><strong>The development tools are thrown out.</strong><code>vaadin-dev</code> brings the development
mode — live reload, verbose error pages, a toolbar in the browser. None of that
has any business on a server, and not merely for reasons of decency: these
tools reveal the application&rsquo;s internals.</p><p><strong>The frontend is built.</strong><code>build-frontend</code> produces an optimized bundle from
the TypeScript and web component sources. Without this step, the application
expects a development server at runtime that does not exist on the production
server — the page stays white, and the error only shows up in the browser log.</p><h3 id="vaadin-production-build">Vaadin Production Build</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 clean package</span></span></code></pre></div></div><p>The frontend build needs Node.js. If it is not present, Vaadin downloads it
into the project directory on its own — on the<strong>development machine</strong>, not on
the server. Chapter 5 comes back to this.</p><h3 id="war-creation">WAR Creation</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">ls -lh target/*.war</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>-rw-r--r-- 1 user staff 21M target/ROOT.war</code></pre></div><p>The file name is no accident. Jetty derives the context path from the archive&rsquo;s
name:<code>ROOT.war</code> is served at<code>/</code>,<code>vaadinapp.war</code> at<code>/vaadinapp</code>. Chapter 9
covers this in detail; in the Maven project, the name is fixed via<code>finalName</code>.</p><h3 id="what-actually-gets-deployed">What Actually Gets Deployed</h3><p>A look inside is worthwhile, because it explains the size:</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">unzip -l target/ROOT.war<span class="p">|</span> tail -1</span></span><span class="line"><span class="cl">unzip -l target/ROOT.war<span class="p">|</span> grep -c<span class="s1">'VAADIN/'</span></span></span><span class="line"><span class="cl">unzip -l target/ROOT.war<span class="p">|</span> grep -c<span class="s1">'WEB-INF/lib/.*\.jar'</span></span></span></code></pre></div></div><table><thead><tr><th/><th/></tr></thead><tbody><tr><td>Total entries</td><td>336</td></tr><tr><td>Files under<code>VAADIN/</code></td><td>142</td></tr><tr><td>Libraries in<code>WEB-INF/lib</code></td><td>86</td></tr><tr><td>Largest library</td><td><code>bcprov-jdk18on</code> at 8.9 MB</td></tr><tr><td>Vaadin itself</td><td><code>flow-server</code> at 2.1 MB</td></tr></tbody></table><p>Of the 21 MB, by far the largest share goes to bundled libraries, not to your
own code. That is normal for a WAR and, in Part 4, the starting point for the
question of whether it can be packaged differently.</p><p>The cross-check for a genuine production build:</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">unzip -l target/ROOT.war<span class="p">|</span> grep -iE<span class="s1">'vaadin-dev|devmode|vite\.generated'</span></span></span></code></pre></div></div><p>No hit. Anyone who finds something here forgot the profile.</p><h3 id="the-second-cross-check-who-supplies-the-server">The Second Cross-Check: Who Supplies the Server?</h3><p>A WAR belongs in a container, and the container brings the server. A WAR that
also contains the server delivers a second copy of exactly the software it runs
in. The check is one line:</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">unzip -l target/ROOT.war<span class="p">|</span> grep -i<span class="s1">'WEB-INF/lib/.*jetty'</span></span></span></code></pre></div></div><p>Expected: no output.</p><p>In this project, there initially was output here, and a long one at that —
fifteen Jetty archives, among them<code>jetty-server</code> and<code>jetty-util</code>, a good
three megabytes altogether. The cause was not a wrong dependency but a missing
declaration. The same application starts with embedded Jetty in the later
parts; the launcher class responsible for that lives in<code>src/main/java</code> and is
therefore compiled in every build. Its dependency stood in the<code>pom.xml</code>
without a<code>&lt;scope&gt;</code>, that is, at<code>compile</code> — and with that, the entire embedded
server migrated into the archive.</p><p>The declaration that was missing:</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;dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>com.svenruppert.vaadin<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>nano-vaadin-jetty<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;version&gt;</span>${nano-vaadin-jetty.version}<span class="nt">&lt;/version&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;scope&gt;</span>provided<span class="nt">&lt;/scope&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/dependency&gt;</span></span></span></code></pre></div></div><p><code>provided</code> means: present for compilation, absent from packaging.<code>jakarta.servlet-api</code> in the same project already plays exactly this role — the
Servlet API is needed for compilation and supplied by the container at runtime.
The same applies to the server; it had simply been forgotten there.</p><h4 id="why-a-superfluous-archive-is-not-a-harmless-archive">Why a Superfluous Archive Is Not a Harmless Archive</h4><p>The application ran before, too. It ran because Jetty&rsquo;s<code>WebAppContext</code> uses a
class loader with precedence rules: packages below<code>org.eclipse.jetty</code> count as<em>server classes</em> and are withdrawn from the web application&rsquo;s classpath. The
container wins; the bundled copy lies there untouched.</p><p>This protection holds, however, only as long as both copies match. If<code>/opt/jetty</code> moves to a different 12.1.x than the one the WAR was built
against, the failure picture is no longer &ldquo;startup aborted&rdquo; but &ldquo;at an
unshielded spot, the wrong class wins.&rdquo; Such errors appear late, often under
load, and the message does not name the cause. A WAR without a second server
cannot produce this case.</p><p>The difference in numbers:</p><table><thead><tr><th/><th>before</th><th>after</th></tr></thead><tbody><tr><td>Archive size</td><td>28 MB</td><td>23 MB</td></tr><tr><td>Libraries in<code>WEB-INF/lib</code></td><td>126</td><td>90</td></tr><tr><td>Jetty archives</td><td>15</td><td>0</td></tr></tbody></table><p>The difference is bigger than the three megabytes of Jetty, and that has a
second reason: through the same dependency, the full Vaadin package including
the commercial Pro components also entered the build, although the<code>pom.xml</code>
deliberately declares<code>vaadin-core</code>. None of these components is used in the
source code. The archive now matches what the project declares — an incidental
finding that would have remained undiscovered without the look into<code>WEB-INF/lib</code>.</p><h4 id="what-was-still-superfluous-after-that">What Was Still Superfluous After That</h4><p>The look is worth taking a second time, because<code>vaadin-core</code> itself also ships
more than most applications need. Via<code>vaadin-core-internal</code> and<code>vaadin-core-components</code>, among other things the AI components and the
Collaboration Engine come in. Neither is used here by the application or by any
bundled library — and the AI components alone account for<code>reactor-core</code> at
1.8 MB:</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;dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>com.vaadin<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>vaadin-core<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;exclusions&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;exclusion&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>com.vaadin<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>vaadin-ai-components-flow<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/exclusion&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;exclusion&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>com.vaadin<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>collaboration-engine<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/exclusion&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/exclusions&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/dependency&gt;</span></span></span></code></pre></div></div><div class="pull-quote"><p><strong>The pitfall here:</strong> The<code>production</code> profile declares<code>vaadin-core</code> a
second time in order to exclude the development tools there. A profile that
declares the same artifact again<strong>replaces</strong> its exclusion list instead of
extending it. Anyone who notes the exclusions only in the main<code>&lt;dependencies&gt;</code> will find them effective in the development build and lifted
again in the production WAR — that is, exactly where it matters. The list
belongs in both places.</p></div><p>That leaves 21 MB and 86 libraries:</p><table><thead><tr><th/><th>starting point</th><th>without Jetty</th><th>without unused parts</th></tr></thead><tbody><tr><td>Archive size</td><td>28 MB</td><td>23 MB</td><td><strong>21 MB</strong></td></tr><tr><td>Libraries</td><td>126</td><td>90</td><td><strong>86</strong></td></tr></tbody></table><p>A quarter of the archive, without changing a single line of application code.</p><h4 id="where-the-sensible-limit-lies">Where the Sensible Limit Lies</h4><p>Going further would be possible and would be wrong. Two examples.</p><p>The application uses 16 component packages; about 57 are shipped. Excluding the
rest individually would bring roughly 1.5 MB — and would have to be maintained
with every Vaadin upgrade. It breaks the moment a library reaches for a
component the application itself does not use; the login form is exactly such a
case. The yield is out of all proportion to the risk of breakage.</p><p>The largest remaining item is<code>bcprov-jdk18on</code> at 7.7 MB — 35% of the archive.
It provides Argon2id for the password hashes. Anyone who removes it saves a
third and falls back to PBKDF2. That is no longer a size question but a
security decision, and in a setup that is otherwise carefully hardened, it is
easy to answer: disk space costs nothing, the hashing algorithm does.</p><p><strong>The size of an archive is a hint, not a goal.</strong> It is worth the look, because
unusual numbers point to dependencies nobody ordered — and that is exactly how
the Jetty find above was discovered.</p><div class="pull-quote"><p><strong>What of this remains in Part 3:</strong> There, Jetty deliberately becomes part of
the application — the same archives, but then in the right place and without
a container around them. The dashed boundary from the diagram in Chapter 1
moves left by exactly these fifteen files. The difference between Part 1 and
Part 3 is not whether Jetty is shipped, but whether shipping it is the
archive&rsquo;s job.</p></div><h2 id="separating-build-and-runtime-environments">Separating Build and Runtime Environments</h2><p>The server from Part 0 has no Maven, no Node.js, and no Git. That is not
negligence but a decision.</p><h3 id="two-machines-two-roles">Two Machines, Two Roles</h3><p><strong>The build machine.</strong> The development machine holds everything needed for
building: JDK, Maven, Node.js, the project directory with version control. That
is where the WAR from Chapter 4 comes into being.</p><p><strong>The production server.</strong> The server runs only what executes the finished
application: a Java runtime, a servlet container, a reverse proxy. None of it
can compile, download, or unpack.</p><h3 id="why-build-tools-get-in-the-way-on-the-target-system">Why Build Tools Get in the Way on the Target System</h3><p>The convenient path would be to clone the project onto the server and build
there. Four reasons speak against it.</p><p><strong>Attack surface.</strong> A build tool downloads dependencies from the network and
executes them. A Maven build runs foreign code — every plugin is a program. On
a publicly reachable server, you do not want that.</p><p><strong>Reproducibility.</strong> A build on the server depends on the state of the server.
Two servers, two results — and when an error occurs, it is unclear whether it
sits in the code or in the environment.</p><p><strong>State.</strong> A build leaves intermediate results behind:<code>target/</code>, a dependency
cache, a Node directory. That is hundreds of megabytes nobody maintains and
that get in the way during the next troubleshooting session.</p><p><strong>Time.</strong> The frontend build takes minutes. During that time, the server runs
at full load — the same server that serves the application.</p><h3 id="required-runtime-components">Required Runtime Components</h3><p>What is actually needed on the server is modest:</p><table><thead><tr><th>Component</th><th>Purpose</th><th>Chapter</th></tr></thead><tbody><tr><td>JDK</td><td>runs the application</td><td>6</td></tr><tr><td>Jetty</td><td>servlet container</td><td>7</td></tr><tr><td>Caddy</td><td>reverse proxy, TLS</td><td>12</td></tr></tbody></table><p>Everything else — Maven, Node.js, npm, Git — stays on the development machine.</p><h3 id="the-seam-between-the-two">The Seam Between the Two</h3><p>Exactly one file is transferred:</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 target/ROOT.war sven@demo.svenruppert.com:/home/sven/</span></span></code></pre></div></div><p>21 MB, a few seconds. Part 2, Chapter 8 turns this into a repeatable procedure.</p><p>This seam is also the spot where a delivery pipeline would attach: it replaces
the<code>scp</code> and the subsequent restart, nothing more. Anyone who knows the seam
can automate it.</p><h3 id="what-the-artifact-does-not-bring-along">What the Artifact Does Not Bring Along</h3><p>The separation has a flip side that shows only at the first deployment.</p><p>A Maven project can define options for the Java runtime — for instance in<code>.mvn/jvm.config</code>. This file configures the<strong>JVM that Maven runs in</strong>: during
compilation and while running the tests. It is part of the project, not part of
the result.</p><p>If the application needs the same options<strong>at runtime</strong> as well, this produces
an error that never occurs on the development machine: there, Maven sets the
options; on the server, the application runs without them. The build is green,
the deployment fails.</p><p>For the sample application, that is exactly the case — it needs two exports for
internal platform interfaces, because its data store accesses them. Chapter 10
shows where they belong.</p><p><strong>The rule behind it:</strong> A setting in the build tool is not a property of the
artifact. What is needed at runtime belongs where the application is started —
and in the project documentation, so that whoever deploys it can know about it
at all.</p><h3 id="a-word-on-the-jdk-version-on-both-sides">A Word on the JDK Version on Both Sides</h3><p>The development machine and the server run the same JDK version, here
Temurin 26. It is not required — Java bytecode is backward compatible, but an
artifact compiled with JDK 26 against<code>release 26</code> also needs at least JDK 26
at runtime.</p><p>The same version on both sides spares you an entire class of errors that only
show at runtime. Chapter 6 shows why this costs little with SDKMAN.</p><h2 id="java-on-the-server">Java on the Server</h2><p>The server from Part 0 has no Java yet. That changes now — and differently than
you usually see it.</p><h3 id="jdk-or-jre">JDK or JRE</h3><p>A full<strong>JDK</strong> is installed, not a mere runtime environment. A JRE would
suffice to run the application;<code>jlink</code> and<code>jdeps</code>, which Part 6 needs, are
only included in the JDK, however. The difference amounts to a few hundred
megabytes and saves a second installation later.</p><h3 id="not-via-the-package-manager">Not via the Package Manager</h3><p>The obvious route would be the Adoptium repository for<code>apt</code>. Instead,<strong>SDKMAN</strong> is used here, and<strong>per service account</strong>.</p><p>Three reasons speak for it. Several JDK versions can be kept side by side and
switched via a symlink — a switch is thus the same move as the Jetty switch
from Chapter 7, and so is a retreat. For Part 6, where different JDK versions
are tested against each other, this is the more practical foundation. And it is
the same toolchain as on the development machine — Chapter 5 separates build
and runtime, not the tools.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt-get install curl zip unzip</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">sudo -i</span></span><span class="line"><span class="cl"><span class="nb">export</span><span class="nv">SDKMAN_DIR</span><span class="o">=</span>/var/lib/vaadinapp/.sdkman</span></span><span class="line"><span class="cl">curl -s<span class="s2">"https://get.sdkman.io?rcupdate=false"</span><span class="p">|</span> bash</span></span><span class="line"><span class="cl"><span class="nb">echo</span><span class="s1">'sdkman_auto_answer=true'</span> &gt;&gt;<span class="nv">$SDKMAN_DIR</span>/etc/config</span></span></code></pre></div></div><p><code>zip</code> and<code>unzip</code> are missing on a minimal Debian installation.<code>rcupdate=false</code> prevents SDKMAN from creating a<code>.bashrc</code> — the service
account has<code>nologin</code> and needs no shell initialization.</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="nb">source</span><span class="nv">$SDKMAN_DIR</span>/bin/sdkman-init.sh</span></span><span class="line"><span class="cl">sdk list java<span class="p">|</span> grep -i temurin</span></span><span class="line"><span class="cl">sdk install java 26.0.2+1.1-tem</span></span><span class="line"><span class="cl">sdk default java 26.0.2+1.1-tem</span></span></code></pre></div></div><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">/var/lib/vaadinapp/.sdkman/candidates/java/current/bin/java -version</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>openjdk version "26.0.2.1" 2026-08-18
OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1)
OpenJDK 64-Bit Server VM Temurin-26.0.2.1+1 (build 26.0.2.1+1, mixed mode, sharing)</code></pre></div><p>The<code>current</code> symlink is the real gain: it is the only path the service
definition knows. A JDK switch then consists of<code>sdk install</code>,<code>sdk default</code>,
and a restart of the service.</p><h3 id="the-price-of-this-decision">The Price of This Decision</h3><p>SDKMAN lives in the service account&rsquo;s home directory — that is, inside the area
Chapter 11 opens for writing. In the naive version,<strong>the service can replace
its own JDK.</strong> Anyone who takes over the application replaces<code>current/bin/java</code> and executes arbitrary code as the service at the next
restart. The entire hardening would be bypassed.</p><p>The resolution is a question of ownership:</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 chown -R root:vaadinapp /var/lib/vaadinapp/.sdkman</span></span><span class="line"><span class="cl">sudo chmod -R g-w,o-rwx /var/lib/vaadinapp/.sdkman</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">sudo chown root:vaadinapp /var/lib/vaadinapp</span></span><span class="line"><span class="cl">sudo chmod<span class="m">750</span> /var/lib/vaadinapp</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">sudo install -d -m<span class="m">700</span> -o vaadinapp -g vaadinapp /var/lib/vaadinapp/work</span></span><span class="line"><span class="cl">sudo install -d -m<span class="m">700</span> -o vaadinapp -g vaadinapp /var/lib/vaadinapp/logs</span></span><span class="line"><span class="cl">sudo install -d -m<span class="m">700</span> -o vaadinapp -g vaadinapp /var/lib/vaadinapp/data</span></span></code></pre></div></div><p><strong>The subtle point lies in the parent directory.</strong> It is not enough for<code>.sdkman</code> to belong to<code>root</code>. Anyone allowed to write to a directory may
rename the entries in it — regardless of who owns them. If the home directory
still belonged to the service, it could push<code>.sdkman</code> aside and put its own in
its place. Only when the home directory belongs to<code>root</code> as well is the path
closed.</p><p>The test:</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 touch /var/lib/vaadinapp/.sdkman/EINDRINGLING</span></span><span class="line"><span class="cl"><span class="c1"># touch: cannot touch ...: Permission denied</span></span></span><span class="line"><span class="cl">sudo -u vaadinapp touch /var/lib/vaadinapp/NEUES_JDK</span></span><span class="line"><span class="cl"><span class="c1"># touch: cannot touch ...: Permission denied</span></span></span><span class="line"><span class="cl">sudo -u vaadinapp touch /var/lib/vaadinapp/data/ok</span></span><span class="line"><span class="cl"><span class="c1"># (works)</span></span></span></code></pre></div></div><h3 id="no-system-wide-java">No System-Wide Java</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="nb">command</span> -v java</span></span></code></pre></div></div><p>No output. There is<strong>no</strong> system-wide JDK on the server — the service account
has its own, nobody else. That prevents a second installation from being used
by accident or a forgotten one from being maintained, and it is the first step
in the direction Part 6 takes to its conclusion: there, the runtime becomes
part of the release artifact.</p><h2 id="jetty-as-an-external-runtime">Jetty as an External Runtime</h2><h3 id="why-121-and-not-120">Why 12.1 and Not 12.0</h3><p>Chapter 3 already named it; here comes the check. Jetty 12 supports several
Jakarta EE generations in separate module branches. Vaadin 25 needs<code>ee11</code>, and
only the 12.1 line carries it.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-home/maven-metadata.xml<span class="se">\</span></span></span><span class="line"><span class="cl"><span class="p">|</span> grep -o<span class="s1">'&lt;version&gt;12\.1\.[^&lt;]*&lt;/version&gt;'</span></span></span></code></pre></div></div><h3 id="obtaining-and-verifying">Obtaining and Verifying</h3><p>The distribution is obtained from Maven Central, not from the Debian package
sources — the versions there are historically outdated.</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">V</span><span class="o">=</span>12.1.12</span></span><span class="line"><span class="cl"><span class="nv">BASE</span><span class="o">=</span>https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-home/<span class="nv">$V</span></span></span><span class="line"><span class="cl"><span class="nb">cd</span> /tmp</span></span><span class="line"><span class="cl">curl -fsSL -O<span class="nv">$BASE</span>/jetty-home-<span class="nv">$V</span>.tar.gz</span></span><span class="line"><span class="cl">curl -fsSL -O<span class="nv">$BASE</span>/jetty-home-<span class="nv">$V</span>.tar.gz.sha512</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">sha512sum -c &lt;<span class="o">(</span><span class="nb">echo</span><span class="s2">"</span><span class="k">$(</span>cat jetty-home-<span class="nv">$V</span>.tar.gz.sha512<span class="k">)</span><span class="s2"> jetty-home-</span><span class="nv">$V</span><span class="s2">.tar.gz"</span><span class="o">)</span></span></span></code></pre></div></div><p>The distribution is about 44 MB. Verifying the checksum costs one line and, for
a component that is unpacked as<code>root</code>, is no formality.</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 tar xzf jetty-home-<span class="nv">$V</span>.tar.gz -C /opt</span></span><span class="line"><span class="cl">sudo ln -sfn /opt/jetty-home-<span class="nv">$V</span> /opt/jetty</span></span><span class="line"><span class="cl">sudo chown -R root:root /opt/jetty-home-<span class="nv">$V</span></span></span></code></pre></div></div><h3 id="jetty_home-and-jetty_base">JETTY_HOME and JETTY_BASE</h3><p>This separation is Jetty&rsquo;s core concept and at the same time what disappears in
Part 3.</p><table><thead><tr><th/><th>Path</th><th>Contents</th></tr></thead><tbody><tr><td><code>JETTY_HOME</code></td><td><code>/opt/jetty</code></td><td>the distribution, unchanged</td></tr><tr><td><code>JETTY_BASE</code></td><td><code>/opt/vaadinapp</code></td><td>everything application-specific</td></tr></tbody></table><p><code>JETTY_HOME</code> is never touched. A new Jetty version is unpacked next to it and
the symlink is repointed; in case of failure, the same path leads back. The
configuration remains unaffected, because it lives in<code>JETTY_BASE</code>.</p><h3 id="required-modules">Required Modules</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="nb">cd</span> /opt/vaadinapp</span></span><span class="line"><span class="cl">sudo java -jar /opt/jetty/start.jar<span class="se">\</span></span></span><span class="line"><span class="cl"> --add-modules<span class="o">=</span>server,http,ee11-deploy,ee11-annotations,ee11-websocket-jakarta</span></span></code></pre></div></div><p>Four entries, each for a concrete reason:</p><table><thead><tr><th>Module</th><th>Purpose</th></tr></thead><tbody><tr><td><code>server</code>,<code>http</code></td><td>core and HTTP connector</td></tr><tr><td><code>ee11-deploy</code></td><td>serves WARs from<code>webapps/</code></td></tr><tr><td><code>ee11-annotations</code></td><td>finds Vaadin&rsquo;s<code>ServletContainerInitializer</code> —<strong>without this module, Vaadin does not start</strong></td></tr><tr><td><code>ee11-websocket-jakarta</code></td><td>Jakarta WebSocket, prerequisite for Vaadin Push (Part 2, Chapter 5)</td></tr></tbody></table><p>Jetty pulls in the dependencies on its own —<code>ee11-webapp</code>,<code>ee11-servlet</code>,<code>sessions</code>,<code>security</code>,<code>logging-jetty</code>, and more — and creates an<code>.ini</code> under<code>start.d/</code> for each module. That is the configuration that gets adjusted later.</p><p><code>ee11-annotations</code> deserves the emphasis: Vaadin registers its servlets via a<code>ServletContainerInitializer</code> that the container must find at startup. If the
module is missing, Jetty starts without an error message and answers every
request with 404 — a failure picture you search for a long time without this
hint.</p><h3 id="server-configuration">Server Configuration</h3><p>The state can be queried at any time:</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="nb">cd</span> /opt/vaadinapp</span></span><span class="line"><span class="cl">java -jar /opt/jetty/start.jar --list-modules<span class="o">=</span>*</span></span><span class="line"><span class="cl">ls -1 start.d/</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>bytebufferpool.ini ee11-deploy.ini http.ini sessions.ini
deployer-standard.ini ee11-webapp.ini scheduler.ini threadpool.ini
deployment-scanner.ini ee11-websocket-jakarta.ini server.ini
ee11-annotations.ini ee-webapp.ini http-config.ini</code></pre></div><h2 id="making-jetty-reachable-only-locally">Making Jetty Reachable Only Locally</h2><p>After installation, Jetty listens on all addresses. That changes now, and it is
the first of two lines of defense.</p><h3 id="bind-address">Bind Address</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">sudo nano /opt/vaadinapp/start.d/http.ini</span></span></code></pre></div></div><p>Two commented-out lines are activated:</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>jetty.http.host=127.0.0.1
jetty.http.port=8080</code></pre></div><h3 id="why-this-is-the-first-line-and-not-the-second">Why This Is the First Line and Not the Second</h3><p>The firewall from Part 0 lets only 22, 80, and 443 through inbound; port 8080
would therefore be unreachable anyway. Why restrict the bind address on top of
that?</p><p>Because the two measures catch different mistakes. A firewall rule can
accidentally be drawn too wide, a rule set can fail to apply after a reboot, a
second network adapter can appear. If the service does not listen outward in
the first place, none of these cases is dangerous.</p><p>The reverse holds as well: if a service accidentally binds to<code>0.0.0.0</code>, the
firewall catches it. Two independent measures that produce the same state —
that is intent, not redundancy.</p><h3 id="proof">Proof</h3><p>Claiming is not enough. On the server:</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">ss -tlnp<span class="p">|</span> grep<span class="m">8080</span></span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>LISTEN 0 50 [::ffff:127.0.0.1]:8080 users:(("java",pid=...))</code></pre></div><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">curl -s -o /dev/null -w<span class="s1">'%{http_code}\n'</span> http://127.0.0.1:8080/</span></span><span class="line"><span class="cl"><span class="c1"># 200</span></span></span></code></pre></div></div><p>And from the workstation, because only that is real proof:</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">nc -z -w<span class="m">4</span> demo.svenruppert.com<span class="m">8080</span><span class="o">&amp;&amp;</span><span class="nb">echo</span> open<span class="o">||</span><span class="nb">echo</span> filtered</span></span><span class="line"><span class="cl"><span class="c1"># filtered</span></span></span></code></pre></div></div><h3 id="the-application-port-stays-closed">The Application Port Stays Closed</h3><p>Port 8080 is not opened anywhere in the firewall — not even &ldquo;temporarily for
testing.&rdquo; Everything that comes from outside goes through Caddy; from Part 2,
Chapter 2 on, that is the only way in.</p><p>This determination has a practical side effect: anyone who wants to reach Jetty
directly during troubleshooting builds an SSH tunnel:</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">ssh -L 8080:127.0.0.1:8080 sven@demo.svenruppert.com</span></span></code></pre></div></div><p>After that,<code>http://localhost:8080/</code> on your own machine is the server&rsquo;s Jetty
— without any port having been opened.</p><h2 id="deploying-the-vaadin-war">Deploying the Vaadin WAR</h2><h3 id="deployment-directory">Deployment Directory</h3><p><code>ee11-deploy</code> watches a directory below<code>JETTY_BASE</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>/opt/vaadinapp/webapps/</code></pre></div><p>What lies there gets served. No more configuration is needed — there is no
registration file and no entry in a central list.</p><h3 id="context-path">Context Path</h3><p>Jetty derives the context path from the file name. That is convenient and one
of the most common pitfalls:</p><table><thead><tr><th>File</th><th>Reachable at</th></tr></thead><tbody><tr><td><code>ROOT.war</code></td><td><code>/</code></td></tr><tr><td><code>vaadinapp.war</code></td><td><code>/vaadinapp</code></td></tr><tr><td><code>vaadinapp-00.01.00.war</code></td><td><code>/vaadinapp-00.01.00</code></td></tr></tbody></table><p>The last case is the annoying one: anyone who copies the Maven artifact
unchanged gets the version number into the URL — and a different one with the
next release. That is why the project fixes the name<code>ROOT</code> via<code>finalName</code>.</p><p>The application runs at<code>/</code> here, that is, one hostname for one application.
Anyone running several applications on one server assigns subpaths — but then
Caddy has to rewrite as well, and Vaadin has to generate its URLs accordingly,
including the push connection from Part 2, Chapter 5. For one application per
hostname, all of this simply goes away.</p><h3 id="transferring-and-placing">Transferring and Placing</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">scp target/ROOT.war sven@demo.svenruppert.com:/home/sven/</span></span></code></pre></div></div><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 -m<span class="m">640</span> -o root -g vaadinapp /home/sven/ROOT.war<span class="se">\</span></span></span><span class="line"><span class="cl"> /opt/vaadinapp/webapps/ROOT.war</span></span></code></pre></div></div><p><code>install</code> instead of<code>cp</code>, because it sets permissions and owner in one step.
With<code>cp</code>, the source file&rsquo;s permissions would survive — a WAR from the build
directory is typically<code>644</code> and belongs to the user who built it.</p><h3 id="ownership-and-file-permissions">Ownership and File Permissions</h3><p><code>root:vaadinapp</code> with<code>640</code>. The service may<strong>read, not write.</strong></p><p>That is the principle from Part 0, Chapter 9, applied here: anyone who takes
over the application can do what the application may do — but they cannot swap
out the artifact on disk. A restart brings back the unmodified state.</p><h3 id="where-jetty-unpacks-to">Where Jetty Unpacks To</h3><p>A WAR is an archive; Jetty unpacks it at startup.<strong>Not</strong> to<code>webapps/</code>, but to<code>java.io.tmpdir</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>Started WebAppContext{ROOT,/,b=file:///var/lib/vaadinapp/work/jetty-127_0_0_1-8080-ROOT_war-_-any-,…}</code></pre></div><p>That is the reason why the service definition in Chapter 10 sets this directory
explicitly. With the default<code>/tmp</code> it works at first, too — until Chapter 11
makes the file system read-only.</p><h3 id="the-relationship-between-jetty-and-the-application">The Relationship Between Jetty and the Application</h3><p>At this point it is worth naming the state, because it disappears in Part 3:</p><p><strong>Jetty knows nothing about this application.</strong> It finds an archive in a
directory, unpacks it, and starts what is inside. There is no dependency from
container to application, only the other way around.</p><p><strong>The application knows nothing about this Jetty.</strong> It contains no startup code
and no server settings. It could run in Tomcat without the archive changing at
all.</p><p>Both are updated separately: Jetty via the symlink from Chapter 7, the
application via swapping the archive. Exactly this independence is what Part 3
gives up — there, Jetty becomes a library of the application, and from then on
both share one lifecycle.</p><h2 id="jetty-as-a-systemd-service">Jetty as a systemd Service</h2><p>Started by hand, Jetty runs as long as the session lasts. For operations, that
is nothing. The service must start at boot, revive itself after a crash, and
leave its output somewhere it can be found again.</p><h3 id="service-user">Service User</h3><p>The service runs under the<code>vaadinapp</code> account from Part 0 — a system account
without login capability that may not write its own program files.</p><h3 id="unit-file">Unit File</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">sudo nano /etc/systemd/system/vaadinapp.service</span></span></code></pre></div></div><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="k">[Unit]</span></span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">vaadinapp - Vaadin auf Jetty 12 (ee11)</span></span></span><span class="line"><span class="cl"><span class="na">Documentation</span><span class="o">=</span><span class="s">https://jetty.org/docs/</span></span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network-online.target</span></span></span><span class="line"><span class="cl"><span class="na">Wants</span><span class="o">=</span><span class="s">network-online.target</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="k">[Service]</span></span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">simple</span></span></span><span class="line"><span class="cl"><span class="na">User</span><span class="o">=</span><span class="s">vaadinapp</span></span></span><span class="line"><span class="cl"><span class="na">Group</span><span class="o">=</span><span class="s">vaadinapp</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Configuration outside the artifact; "-" = file may be absent</span></span></span><span class="line"><span class="cl"><span class="na">EnvironmentFile</span><span class="o">=</span><span class="s">-/etc/vaadinapp/environment</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="na">WorkingDirectory</span><span class="o">=</span><span class="s">/opt/vaadinapp</span></span></span><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"> -Djetty.home=/opt/jetty \</span></span></span><span class="line"><span class="cl"><span class="s"> -Djetty.base=/opt/vaadinapp \</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"> -jar /opt/jetty/start.jar</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Jetty exits with 143 on SIGTERM - that is not an error</span></span></span><span class="line"><span class="cl"><span class="na">SuccessExitStatus</span><span class="o">=</span><span class="s">143</span></span></span><span class="line"><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">on-failure</span></span></span><span class="line"><span class="cl"><span class="na">RestartSec</span><span class="o">=</span><span class="s">5s</span></span></span><span class="line"><span class="cl"><span class="na">TimeoutStopSec</span><span class="o">=</span><span class="s">30</span></span></span><span class="line"><span class="cl"><span class="na">KillSignal</span><span class="o">=</span><span class="s">SIGTERM</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="na">StandardOutput</span><span class="o">=</span><span class="s">journal</span></span></span><span class="line"><span class="cl"><span class="na">StandardError</span><span class="o">=</span><span class="s">journal</span></span></span><span class="line"><span class="cl"><span class="na">SyslogIdentifier</span><span class="o">=</span><span class="s">vaadinapp</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="k">[Install]</span></span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">multi-user.target</span></span></span></code></pre></div></div><h3 id="execstart">ExecStart</h3><p>What is invoked is<code>java -jar start.jar</code> directly,<strong>not</strong> the bundled<code>jetty.sh</code>. That script brings its own backgrounding and PID logic, which
overlaps with systemd — two instances that both want to manage the same
process.</p><p>The direct invocation has a second advantage that only becomes visible in the
following parts: in this part, systemd starts the servlet container; from
Part 3 on, it starts the application. The service definition barely changes in
the process — the break you would expect does not happen.</p><p>The path to<code>java</code> is absolute and points to the symlink from Chapter 6. A JDK
switch changes the symlink&rsquo;s target, not this file.</p><h3 id="the-two-module-options">The Two Module Options</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>--add-exports java.base/jdk.internal.misc=ALL-UNNAMED
--enable-native-access=ALL-UNNAMED</code></pre></div><p>The sample application stores its data via Eclipse Store, whose serialization
accesses internal interfaces of the Java platform. Since the module system,
those are closed by default; without these two exports, the first write
operation fails.</p><p>Why they stand exactly here and not in the archive is covered in Chapter 5. The
short version: such options are<strong>properties of the launch, not of the
artifact.</strong> A WAR cannot bring them along — whoever deploys it has to know
them.</p><p>What happens when they are missing is unpleasant: the application starts,
answers with 200, and appears healthy. Only the first write operation fails —
and depending on how the application handles that, you notice it two steps
later in an entirely different place.</p><h3 id="successexitstatus143">SuccessExitStatus=143</h3><p>This line looks like cosmetics and is not. 143 is 128 + 15, that is,
termination by SIGTERM — exactly what<code>systemctl stop</code> triggers.</p><p>Without this entry, systemd logs<strong>every regular stop as a failure</strong>. That does
not merely distort the status display: combined with<code>Restart=on-failure</code>, the
service would come back up after an intentional stop.</p><h3 id="restart-policy-and-boot-behavior">Restart Policy and Boot Behavior</h3><p><code>Restart=on-failure</code> restarts after a crash, not after a clean stop.<code>RestartSec=5s</code> prevents a permanently failing service from burdening the
system with start attempts.</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 systemctl daemon-reload</span></span><span class="line"><span class="cl">sudo systemctl<span class="nb">enable</span> --now vaadinapp</span></span></code></pre></div></div><p><code>enable</code> takes care of the start at boot;<code>--now</code> starts immediately.</p><h3 id="journald">journald</h3><p><code>StandardOutput=journal</code> routes all output into the journal instead of writing
it to a file of its own. Rotation and cleanup thus fall away — journald already
handles both.</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">journalctl -u vaadinapp -n<span class="m">30</span></span></span><span class="line"><span class="cl">journalctl -u vaadinapp -f</span></span><span class="line"><span class="cl">journalctl -u vaadinapp --since<span class="s2">"-10min"</span><span class="p">|</span> grep -i error</span></span></code></pre></div></div><h3 id="proof-1">Proof</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">systemctl is-active vaadinapp</span></span><span class="line"><span class="cl">ps -o user,pid,cmd -C java --no-headers</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>active
vaadina+ 4440 /var/lib/vaadinapp/.sdkman/candidates/java/current/bin/java -Djetty.home=…</code></pre></div><p>The process runs under the service account, not as<code>root</code> — this is the point
where the groundwork from Part 0 pays off.</p><h2 id="systemd-hardening">systemd Hardening</h2><p>The service runs under its own account. That separates it from other users —
but it may still do everything this account may do: read the file system, open
arbitrary network connections, query kernel settings, look at other processes.</p><h3 id="why-this-is-not-a-theoretical-concern">Why This Is Not a Theoretical Concern</h3><p>Part 0, Chapter 7 gave the numbers: the first foreign login attempt stood in
the log<strong>twenty-three seconds after system startup</strong>, and since then roughly
150 attempts per hour have been running against the SSH access.</p><p>From Part 2, Chapter 3 on, a second front joins. As soon as a TLS certificate
is issued, the hostname becomes publicly known — Part 2, Chapter 4 shows how
fast that happens and what gets probed for then. A publicly reachable web
application is not a quiet place.</p><p>Hardening is therefore not a precaution for the case that somebody drops by.</p><h3 id="measuring-the-baseline">Measuring the Baseline</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">systemd-analyze security vaadinapp.service</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>→ Overall exposure level for vaadinapp.service: 9.2 UNSAFE 😨</code></pre></div><p>The tool rates which restrictions a service definition sets. The value is not a
metric of security, but it is a usable indication of how much damage a
taken-over process could do.</p><h3 id="as-a-drop-in-not-in-the-unit">As a Drop-In, Not in the Unit</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">sudo mkdir -p /etc/systemd/system/vaadinapp.service.d</span></span><span class="line"><span class="cl">sudo nano /etc/systemd/system/vaadinapp.service.d/10-hardening.conf</span></span></code></pre></div></div><p>The separation has a practical reason: the unit describes<strong>what</strong> the service
is; the drop-in,<strong>how</strong> it is locked down. For comparison, the hardening can
be switched off without touching the unit — and when Jetty is updated, the
hardening remains untouched.</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="k">[Service]</span></span></span><span class="line"><span class="cl"><span class="c1"># --- Prevent privilege escalation ---</span></span></span><span class="line"><span class="cl"><span class="na">NoNewPrivileges</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">CapabilityBoundingSet</span><span class="o">=</span></span></span><span class="line"><span class="cl"><span class="na">AmbientCapabilities</span><span class="o">=</span></span></span><span class="line"><span class="cl"><span class="na">RestrictSUIDSGID</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># --- File system ---</span></span></span><span class="line"><span class="cl"><span class="na">ProtectSystem</span><span class="o">=</span><span class="s">strict</span></span></span><span class="line"><span class="cl"><span class="na">ProtectHome</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">ReadWritePaths</span><span class="o">=</span><span class="s">/var/lib/vaadinapp/work /var/lib/vaadinapp/logs /var/lib/vaadinapp/data</span></span></span><span class="line"><span class="cl"><span class="na">PrivateTmp</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">ProtectProc</span><span class="o">=</span><span class="s">invisible</span></span></span><span class="line"><span class="cl"><span class="na">ProcSubset</span><span class="o">=</span><span class="s">pid</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># --- Kernel and devices ---</span></span></span><span class="line"><span class="cl"><span class="na">PrivateDevices</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">ProtectKernelTunables</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">ProtectKernelModules</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">ProtectKernelLogs</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">ProtectControlGroups</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">ProtectClock</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">ProtectHostname</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># --- Network ---</span></span></span><span class="line"><span class="cl"><span class="na">RestrictAddressFamilies</span><span class="o">=</span><span class="s">AF_INET AF_INET6 AF_UNIX</span></span></span><span class="line"><span class="cl"><span class="na">IPAddressDeny</span><span class="o">=</span><span class="s">any</span></span></span><span class="line"><span class="cl"><span class="na">IPAddressAllow</span><span class="o">=</span><span class="s">localhost</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># --- Process ---</span></span></span><span class="line"><span class="cl"><span class="na">RestrictNamespaces</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">RestrictRealtime</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">LockPersonality</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">RemoveIPC</span><span class="o">=</span><span class="s">yes</span></span></span><span class="line"><span class="cl"><span class="na">SystemCallArchitectures</span><span class="o">=</span><span class="s">native</span></span></span><span class="line"><span class="cl"><span class="na">SystemCallFilter</span><span class="o">=</span><span class="s">@system-service</span></span></span><span class="line"><span class="cl"><span class="na">SystemCallFilter</span><span class="o">=</span><span class="s">~@privileged @resources</span></span></span><span class="line"><span class="cl"><span class="na">UMask</span><span class="o">=</span><span class="s">0027</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># --- Resource limits ---</span></span></span><span class="line"><span class="cl"><span class="na">MemoryHigh</span><span class="o">=</span><span class="s">1280M</span></span></span><span class="line"><span class="cl"><span class="na">MemoryMax</span><span class="o">=</span><span class="s">1536M</span></span></span><span class="line"><span class="cl"><span class="na">TasksMax</span><span class="o">=</span><span class="s">256</span></span></span><span class="line"><span class="cl"><span class="na">LimitNOFILE</span><span class="o">=</span><span class="s">65535</span></span></span></code></pre></div></div><h3 id="protectsystemstrict--and-what-it-presupposes"><code>ProtectSystem=strict</code> — and What It Presupposes</h3><p>This one line puts the entire file system in front of the service read-only.
Writable is only what stands under<code>ReadWritePaths</code>.</p><p>That<strong>three paths</strong> suffice is no accident but the payoff of the groundwork
from Part 0, Chapter 10. If program, configuration, and data lived in one
shared directory, exactly that directory would have to be opened up — and with
it the artifact. The measure would formally exist and be practically
ineffective.</p><p><code>IPAddressDeny=any</code> with<code>IPAddressAllow=localhost</code> allows the service loopback
traffic only. For an application behind a reverse proxy, that is right — as
soon as it calls external interfaces, it has to be adjusted.</p><h3 id="resource-limits-and-the-jvm">Resource Limits and the JVM</h3><p><code>MemoryMax</code> is a cgroup limit. The JVM detects it and derives its default heap
from it;<code>-XX:MaxRAMPercentage=75</code> from Chapter 10 refers to it.</p><p>If the values do not fit together, systemd kills the process<strong>before</strong> the JVM
throws its own<code>OutOfMemoryError</code>. The log then shows a meaningless<code>Killed</code>,
and the search starts in the wrong place.</p><p><code>-XX:+ExitOnOutOfMemoryError</code> makes a JVM in memory distress terminate itself
instead of circling endlessly in garbage collection. Only then does<code>Restart=on-failure</code> take effect — without the option, the service formally
keeps running and no longer responds.</p><h3 id="the-directive-that-breaks-every-jvm">The Directive That Breaks Every JVM</h3><p><code>MemoryDenyWriteExecute=yes</code> appears in nearly every hardening template. It
prevents memory regions that are writable and executable at the same time — an
effective bar against an entire class of attacks.</p><p>The JVM&rsquo;s JIT compiler generates machine code at runtime and needs exactly such
memory. With this directive, the service does not start.</p><p>It is deliberately absent here. For Caddy, a Go program without runtime
compilation, the same directive is unproblematic — both services run on the
same server with different sets of directives. A hardening template cannot be
copied from service to service.</p><h3 id="applying-it--and-the-failure">Applying It — and the Failure</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">sudo systemctl daemon-reload</span></span><span class="line"><span class="cl">sudo systemctl restart vaadinapp</span></span></code></pre></div></div><p>The service runs. The application does not:</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">curl -s -o /dev/null -w<span class="s1">'%{http_code}\n'</span> http://127.0.0.1:8080/</span></span><span class="line"><span class="cl"><span class="c1"># 503</span></span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>WARN oeje11w.WebAppContext: Failed startup of context ...{ROOT,/}
Caused by: java.lang.ExceptionInInitializerError
Caused by: org.eclipse.serializer.exceptions.IORuntimeException
Caused by: java.nio.file.FileSystemException:
/opt/vaadinapp/./data: Read-only file system</code></pre></div><p>Jetty has started; the application context has not. The error message names the
reason completely — you just have to read it down to the last<code>Caused by</code> line.</p><p><strong>The application creates its data store under the relative path<code>./data</code>.</strong>
That resolves against the working directory, that is, against<code>/opt/vaadinapp</code>
— and this directory has been read-only for a minute.</p><p>The error is no accident but the inevitable consequence of two decisions that
are each right on their own: an application with a relative default path<strong>must</strong> fail at this hardening.</p><h3 id="the-fix-does-not-belong-in-the-hardening">The Fix Does Not Belong in the Hardening</h3><p>The convenient path would be to add<code>/opt/vaadinapp</code> to<code>ReadWritePaths</code>. The
hardening would then be done — and the service would once again be allowed to
overwrite its own artifact.</p><p>The right path leads through configuration. The application knows a key for its
storage location, and Part 2, Chapter 6 treats the procedure in context. Here,
the line in<code>ExecStart</code> that Chapter 10 already contained suffices:</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>-Dapp.storage.dir=/var/lib/vaadinapp/data</code></pre></div><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 systemctl daemon-reload<span class="o">&amp;&amp;</span> sudo systemctl restart vaadinapp</span></span><span class="line"><span class="cl">curl -s -o /dev/null -w<span class="s1">'%{http_code}\n'</span> http://127.0.0.1:8080/</span></span><span class="line"><span class="cl"><span class="c1"># 200</span></span></span></code></pre></div></div><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 -la /var/lib/vaadinapp/data/</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>drwx------ vaadinapp vaadinapp app-store
drwx------ vaadinapp vaadinapp jcustos
drwx------ vaadinapp vaadinapp jcustos-store</code></pre></div><p>The data now lives where mutable data belongs, and the hardening remains
complete.</p><h3 id="measuring-the-result">Measuring the Result</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">systemd-analyze security vaadinapp.service</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>→ Overall exposure level for vaadinapp.service: 1.1 OK 🙂</code></pre></div><p><strong>From 9.2 to 1.1.</strong></p><h3 id="demonstrating-effectiveness">Demonstrating Effectiveness</h3><p>A number is not proof. The proof is the attempt:</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 systemd-run --uid<span class="o">=</span>vaadinapp<span class="se">\</span></span></span><span class="line"><span class="cl"> --property<span class="o">=</span><span class="nv">ProtectSystem</span><span class="o">=</span>strict<span class="se">\</span></span></span><span class="line"><span class="cl"> --property<span class="o">=</span><span class="nv">ReadWritePaths</span><span class="o">=</span>/var/lib/vaadinapp/data<span class="se">\</span></span></span><span class="line"><span class="cl"> --wait --pipe touch /opt/vaadinapp/EINDRINGLING</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>touch: cannot touch '/opt/vaadinapp/EINDRINGLING': Read-only file system</code></pre></div><p>The cross-check: the service still writes its data, its access log, and its
working directory. The application answers with 200.</p><h3 id="what-remains">What Remains</h3><p><code>systemd-analyze</code> still flags a few things afterward, and that is fine:</p><table><thead><tr><th>Finding</th><th>Justification</th></tr></thead><tbody><tr><td><code>MemoryDenyWriteExecute</code></td><td>see above — the JVM needs it</td></tr><tr><td>Network access</td><td>the service<strong>is</strong> a web server</td></tr><tr><td><code>PrivateUsers</code></td><td>breaks delivery to the service account</td></tr><tr><td><code>RootDirectory</code></td><td>no chroot; would be a project of its own</td></tr></tbody></table><p>Hardening is prioritization, not completeness. A low score for the services
that run your own code on the network is worth more than a mediocre one
everywhere.</p><h2 id="result">Result</h2><p>A Vaadin application runs as its own system service on a Debian server. Three
building blocks, each set up individually, each individually traceable — and
none of them reachable from the internet.</p><h3 id="what-is-running-now">What Is Running Now</h3><table><thead><tr><th>Building block</th><th>State</th></tr></thead><tbody><tr><td>Temurin JDK 26</td><td>via SDKMAN in the service account&rsquo;s home, no system-wide Java</td></tr><tr><td>Jetty 12.1.12</td><td>servlet container,<code>ee11</code>, bound to<code>127.0.0.1:8080</code></td></tr><tr><td><code>ROOT.war</code></td><td>21 MB, 86 libraries, production build, served at<code>/</code></td></tr><tr><td><code>vaadinapp.service</code></td><td>starts at boot, logs to journald, locked down</td></tr></tbody></table><h3 id="the-numbers">The Numbers</h3><table><thead><tr><th/><th/></tr></thead><tbody><tr><td>Jetty start until first response</td><td>4.1 seconds</td></tr><tr><td>Memory footprint at idle</td><td>461 MiB of 1,536 MiB</td></tr><tr><td>Threads</td><td>33</td></tr><tr><td>Hardening score<code>vaadinapp</code></td><td>from 9.2 to 1.1</td></tr><tr><td>Reachable from the internet</td><td>no</td></tr></tbody></table><p>The last line is not a gap; it is the result. It will be lifted deliberately in
Part 2.</p><div class="pull-quote"><p><strong>State of the measurements:</strong> All values in this part were taken on 2026-09-07 on the reference server, release<code>2026-09-07-1627</code>. From Part 3 on, the same server runs the
embedded setup; the numbers here therefore describe the state at that time
and can no longer be reproduced there.</p></div><h3 id="what-became-visible-along-the-way">What Became Visible Along the Way</h3><p>Three observations carry beyond this part.</p><p><strong>Individual measures only work in concert.</strong><code>ProtectSystem=strict</code> in
Chapter 11 becomes effective in the first place because Part 0 put program,
configuration, and data into separate directories. If everything lived
together, the exception list would have to be opened so wide that the directive
would no longer protect anything. A hardening directive is only as good as the
directory layout beneath it.</p><p><strong>Hardening is prioritization, not completeness.</strong><code>systemd-analyze</code> still
flags things after the work is done, and that stays so: the service<strong>is</strong> a
web server, and<code>MemoryDenyWriteExecute</code> breaks every JVM. A low score for the
service that runs your own code on the network is worth more than a mediocre
one everywhere.</p><p><strong>The server is interesting from the first second.</strong> Twenty-three seconds until
the first foreign login attempt — that was the number from Part 0. Anyone who
secures things after something is running has missed the moment. That is
exactly why this part ends here and not only behind the reverse proxy: the
service is fully hardened<strong>before</strong> anyone can reach it from outside.</p><h3 id="what-this-setup-does-not-yet-provide">What This Setup Does Not Yet Provide</h3><p>In all honesty, because Part 2 picks up exactly here:</p><p><strong>Nobody but the server itself can use the application.</strong> A<code>curl</code> against<code>127.0.0.1:8080</code> proves that it runs — nothing more. What is missing is the
public endpoint, the certificate, and everything an application behind a
reverse proxy has to know about itself.</p><p><strong>The application is deployed but not set up.</strong> It comes with login and roles
and needs a first administrator account. That can sensibly be assigned only
once an encrypted connection is available for it.</p><p><strong>There is no procedure for the second release yet.</strong> The WAR sits in its
place, but updating and rolling back are so far manual work without a defined
procedure.</p><h2 id="outlook-on-part-2">Outlook on Part 2</h2><p>The service runs, hardened and invisible. What is missing is the way in from
outside — and that is more than one line of proxy configuration.</p><h3 id="why-the-reverse-proxy-is-a-part-of-its-own">Why the Reverse Proxy Is a Part of Its Own</h3><p>A request that arrives through a reverse proxy is not the same request the
browser sent. It comes from<code>127.0.0.1</code> instead of from the internet, it is
unencrypted instead of over TLS, and it carries a different hostname. An
application that is not told about this draws the wrong conclusions: it sets
session cookies without<code>Secure</code>, builds redirects to<code>http://</code>, and logs the
same address for every access.</p><p>That is not an edge case but the normal case — and it is the reason why Part 2
describes not only how Caddy is installed but also what the application behind
it has to know about itself.</p><h3 id="what-part-2-adds">What Part 2 Adds</h3><table><thead><tr><th>Topic</th><th>Why it belongs</th></tr></thead><tbody><tr><td>Caddy as the public endpoint</td><td>the only service visible from outside</td></tr><tr><td>Domain, HTTPS, automatic certificate</td><td>without TLS, no login worthy of the name</td></tr><tr><td>Forwarding headers</td><td>so the application sees scheme and origin correctly</td></tr><tr><td>Vaadin Push behind the proxy</td><td>long-lived connections need to be passed through explicitly</td></tr><tr><td>Configuration outside the archive</td><td>so the same file may act differently on every server</td></tr><tr><td>Secrets and file permissions</td><td>credentials do not belong in the archive</td></tr><tr><td>Updating, rollback, sessions</td><td>operations after the first start</td></tr></tbody></table><p>At the end of Part 2, the application is reachable over HTTPS under its own
domain name, set up, and equipped with a repeatable procedure for the next
release.</p><h3 id="and-after-that">And After That</h3><p>Only from Part 3 on does the boundary described in Chapter 1 shift. Part 3
turns Jetty into a library of the application, Part 4 changes the form of
delivery, and Part 6 takes the Java runtime inside.</p><p>Parts 1 and 2 together are the starting point these three variants compete
against. They are also the version with the fewest prerequisites — and
therefore the one that carries the longest.</p>
]]></content:encoded><category>Java</category><category>Vaadin</category><media:content url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-1-from-vaadin-project-to-systemd-service-hero.png" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-1-from-vaadin-project-to-systemd-service-hero.png"/><enclosure url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-1-from-vaadin-project-to-systemd-service-hero.png" type="image/jpeg" length="0"/></item><item><title>Vaadin Deployment on Hetzner — Part 2: Publicly Reachable with Caddy and HTTPS</title><link>https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-2-publicly-reachable-with-caddy-and-https/</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-2-publicly-reachable-with-caddy-and-https/</guid><description>Putting a Vaadin app on the public internet with Caddy: automatic HTTPS, forwarded headers, server push, and operating the service without downtime.</description><content:encoded>&lt;![CDATA[<p>Continuation of Part 1 of the series<em>Vaadin – Deployment on Hetzner</em>. From a local service to a publicly reachable application: Caddy, HTTPS, forwarded headers, Push — and day-to-day operations with updates and rollback. All commands, outputs, and measurements come from an actual run on a Debian 13 server.</p><h2 id="starting-point">Starting Point</h2><p>This part picks up where Part 1 left off: a Vaadin application that runs as
its own system service, hardened — and that nobody can reach.</p><h3 id="the-state-everything-builds-on">The state everything builds on</h3><table><thead><tr><th>Building block</th><th>State at the end of Part 1</th></tr></thead><tbody><tr><td>Temurin JDK 26</td><td>via SDKMAN in the service account&rsquo;s home, no system-wide Java</td></tr><tr><td>Jetty 12.1.12</td><td>servlet container,<code>ee11</code>, bound to<code>127.0.0.1:8080</code></td></tr><tr><td><code>ROOT.war</code></td><td>21 MB, production build, served at<code>/</code></td></tr><tr><td><code>vaadinapp.service</code></td><td>starts at boot, logs to journald, hardening score 1.1</td></tr><tr><td>Reachability</td><td>local only</td></tr></tbody></table><p>The cross-check on the server:</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">curl -s -o /dev/null -w<span class="s1">'%{http_code}\n'</span> http://127.0.0.1:8080/</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>200</code></pre></div><p>From your own workstation, on the other hand: nothing. The service is bound to
the loopback address, and the firewall from Part 0 only admits 22, 80, and 443
inbound anyway.</p><p>If you are joining at this point, you should at least have skimmed Part 1 —
above all the chapters on the bind address and the unit file. The decisions
made there are assumed here and exploited in several places.</p><h3 id="the-code-state-for-this-part">The code state for this part</h3><p>As in Part 1: the state this part describes lives on the tag<code>teil-02</code>.</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-02</span></span></code></pre></div></div><p>A single Maven module, built to<code>target/ROOT.war</code>. From Part 3 on, the project
is split into modules; the paths here apply to the single-module state.</p><h3 id="what-this-part-adds">What this part adds</h3><figure><img src="/images/2026/09/vaadin-deployment-on-hetzner-part-2-publicly-reachable-with-caddy-and-https-teil02-anfragepfad.png" alt="Caddy as the public endpoint in front of the service from Part 1" loading="lazy" decoding="async"/><p><em>Figure 1: Caddy goes in front; the service from Part 1 remains unchanged.</em></p><p>Between a running service and a usable application lie more steps than a
single line of proxy configuration suggests.</p><p><strong><a href="https://8g8.eu/gl8qju">Caddy</a></strong> becomes the only service visible from the outside. It accepts the
requests, terminates TLS, obtains and renews the certificate itself, and passes
everything else through to<code>127.0.0.1:8080</code>.</p><p><strong>The application has to learn that it is behind a proxy.</strong> A request that
comes in through Caddy appears, from Jetty&rsquo;s point of view, to originate from<code>127.0.0.1</code>, is unencrypted, and carries a different hostname than the one the
browser called. Without correction, Vaadin sets session cookies without<code>Secure</code> and builds redirects to<code>http://</code>. Chapter 4 deals with that.</p><p><strong>Vaadin Push</strong> keeps a persistent connection open. It survives a
reverse proxy only if the proxy explicitly passes it through.</p><p><strong>After that, operations begin:</strong> configuration outside the archive, secrets
and file permissions, a repeatable procedure for the next release, a
controlled rollback — and the question of what happens to the running
sessions during all of this.</p><h3 id="the-path-from-the-outside">The path from the outside</h3><p>At the end of this part, the application is reachable under its own domain
name over HTTPS, set up, and updatable. The service itself stays exactly where
Part 1 parked it: bound to<code>127.0.0.1</code>, hardened, unchanged. What is added
goes in front — not inside.</p><h2 id="installing-caddy">Installing Caddy</h2><h3 id="the-role-of-the-reverse-proxy">The role of the reverse proxy</h3><p>Jetty listens on the loopback address and cannot be reached from the outside.
Caddy sits exactly in between: it accepts the requests from the internet,
terminates TLS, and forwards them unencrypted to<code>127.0.0.1:8080</code>.</p><p>This separates two jobs that would otherwise have to be handled by the same
process. The application server takes care of the application; everything
related to public reachability — certificates, redirects, compression,
access logging — sits in front of it.</p><h3 id="installation">Installation</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">sudo apt-get install debian-keyring debian-archive-keyring apt-transport-https curl</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">curl -1sLf<span class="s1">'https://dl.cloudsmith.io/public/caddy/stable/gpg.key'</span><span class="se">\</span></span></span><span class="line"><span class="cl"><span class="p">|</span> sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">curl -1sLf<span class="s1">'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt'</span><span class="se">\</span></span></span><span class="line"><span class="cl"><span class="p">|</span> sudo tee /etc/apt/sources.list.d/caddy-stable.list</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">sudo apt-get update</span></span><span class="line"><span class="cl">sudo apt-get install caddy</span></span></code></pre></div></div><h3 id="the-difference-from-part-1-chapter-10">The difference from Part 1, Chapter 10</h3><p>What is remarkable is what does<strong>not</strong> have to be done afterwards:</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">systemctl is-enabled caddy<span class="c1"># enabled</span></span></span><span class="line"><span class="cl">systemctl is-active caddy<span class="c1"># active</span></span></span><span class="line"><span class="cl">getent passwd caddy<span class="c1"># caddy:/var/lib/caddy:/usr/sbin/nologin</span></span></span></code></pre></div></div><p>The service is already running, with its own system account and a finished
unit definition. For Jetty, Chapters 9 and 10 required the same work by hand.</p><p>That is no coincidence but the difference between a distribution and an
application runtime. Caddy is a finished program with a clear operating model;
Jetty is a runtime whose operating model only emerges once an application is
placed inside it.</p><h3 id="prepackaged-does-not-mean-hardened">Prepackaged does not mean hardened</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">systemd-analyze security caddy.service</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>→ Overall exposure level for caddy.service: 8.8 EXPOSED 🙁</code></pre></div><p>The same treatment as in Part 1, Chapter 11 — but explicitly<strong>not</strong> the same
file:</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 mkdir -p /etc/systemd/system/caddy.service.d</span></span><span class="line"><span class="cl">sudo nano /etc/systemd/system/caddy.service.d/10-hardening.conf</span></span></code></pre></div></div><p>The differences are the actual lesson:</p><table><thead><tr><th>Directive</th><th><code>vaadinapp</code></th><th><code>caddy</code></th><th>Reason</th></tr></thead><tbody><tr><td><code>CapabilityBoundingSet</code></td><td>empty</td><td><code>CAP_NET_BIND_SERVICE</code></td><td>Caddy binds 80 and 443</td></tr><tr><td><code>IPAddressDeny=any</code></td><td>yes</td><td><strong>no</strong></td><td>certificate acquisition needs the network</td></tr><tr><td><code>ReadWritePaths</code></td><td><code>/var/lib/vaadinapp/…</code></td><td><code>/var/lib/caddy /var/log/caddy</code></td><td>certificate storage, access log</td></tr><tr><td><code>MemoryDenyWriteExecute</code></td><td><strong>no</strong></td><td><strong>yes</strong></td><td>Go program, no runtime code generation</td></tr></tbody></table><p>Two assumptions can be verified in practice.</p><p><strong>Does Caddy get by without<code>CAP_NET_ADMIN</code>?</strong> The Debian package grants two
capabilities. Keeping only the one for binding privileged ports is 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">grep -E<span class="s1">'^Cap(Prm|Eff|Bnd)'</span> /proc/<span class="k">$(</span>systemctl show -p MainPID --value caddy<span class="k">)</span>/status</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>CapPrm: 0000000000000400
CapEff: 0000000000000400
CapBnd: 0000000000000400</code></pre></div><p><code>0x400</code> is exactly bit 10 —<code>CAP_NET_BIND_SERVICE</code>, nothing else. HTTPS,
HTTP/2, HTTP/3, and certificate acquisition keep working unchanged.</p><p><strong>Does Caddy tolerate<code>MemoryDenyWriteExecute</code>?</strong> Yes. The directive that
dismantles every JVM in Part 1, Chapter 11 is unproblematic 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>→ Overall exposure level for caddy.service: 1.5 OK 🙂</code></pre></div><p>After this chapter, one thing holds: Caddy is the only process reachable from
the internet. What it forwards is decided in Chapter 3.</p><h2 id="domain-and-https">Domain and HTTPS</h2><h3 id="dns">DNS</h3><p>The prerequisite is an A record pointing to the server&rsquo;s public
address:</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">dig +short A demo.svenruppert.com</span></span><span class="line"><span class="cl"><span class="c1"># 95.216.150.155</span></span></span></code></pre></div></div><p>Anyone who additionally publishes an AAAA record has to handle IPv6 end to end —
firewall, Caddy, and application. An AAAA record in front of a firewall
configured only for IPv4 produces partial outages that affect only some
visitors and are therefore hard to track down.</p><h3 id="caddyfile">Caddyfile</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">sudo nano /etc/caddy/Caddyfile</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>demo.svenruppert.com {
reverse_proxy 127.0.0.1:8080
}</code></pre></div><p>Two lines of substance. No certificate path, no ACME client, no
redirect rule, no<code>listen 443 ssl</code>.</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 systemctl reload caddy</span></span></code></pre></div></div><h3 id="automatic-certificates">Automatic certificates</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">sudo journalctl -u caddy -n<span class="m">30</span></span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>"msg":"using ACME account"
"msg":"trying to solve challenge","identifier":"demo.svenruppert.com","challenge_type":"http-01"
"msg":"authorization finalized","authz_status":"valid"
"msg":"certificate obtained successfully","identifier":"demo.svenruppert.com"</code></pre></div><p>Five seconds from configuration to a valid certificate. The challenge runs
over port 80 — which is why it stands open in the firewall even though the
application is meant to be reachable exclusively over HTTPS.</p><h3 id="http-to-https">HTTP to HTTPS</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">curl -sI http://demo.svenruppert.com/<span class="p">|</span> head -3</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>HTTP/1.1 308 Permanent Redirect
Location: https://demo.svenruppert.com/
Server: Caddy</code></pre></div><p>Nobody configured this redirect. Caddy sets it up as soon as a
hostname appears in the Caddyfile.</p><h3 id="tls">TLS</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="nb">echo</span><span class="p">|</span> openssl s_client -servername demo.svenruppert.com<span class="se">\</span></span></span><span class="line"><span class="cl"> -connect demo.svenruppert.com:443 2&gt;/dev/null<span class="se">\</span></span></span><span class="line"><span class="cl"><span class="p">|</span> openssl x509 -noout -subject -issuer -dates</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>subject=CN=demo.svenruppert.com
issuer=C=US, O=Let's Encrypt, CN=YE1
notBefore=Sep 4 08:56:45 2026 GMT
notAfter=Dec 3 08:56:44 2026 GMT</code></pre></div><p>Caddy handles renewal itself, without a cron entry and without additional
software.</p><h4 id="caa-who-may-issue-at-all">CAA: who may issue at all</h4><p>The A record determines where the domain lives. It says nothing about<strong>who</strong>
may issue a certificate for it — by default, the answer is: any
publicly trusted certificate authority.</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>svenruppert.com. CAA 0 issue "letsencrypt.org"</code></pre></div><p>The check is binding for all publicly trusted authorities. The record
is resolved from the full name upwards; an entry on the
registrable domain covers all subdomains as well.</p><p>A record that is too narrow locks you out — and the mistake only shows up at
the next renewal, possibly months later. That is why setting it should be
followed by an actual reissuance, not just a syntax check.</p><div class="pull-quote"><h3 id="sidebar-iodef--the-record-that-does-almost-nothing">Sidebar:<code>iodef</code> — the record that does almost nothing</h3><p>Besides<code>issue</code>, the standard knows a third property:<code>iodef</code> names
an address to which a certificate authority is supposed to send a report
when it has refused an issuance because of the policy. The idea is
compelling — you would learn about an attempt.</p><p>In practice, this does not hold up. The standard phrases the sending as
optional, and<strong>Let&rsquo;s Encrypt does not implement it</strong>. Anyone who sets the
record and relies on it has an alarm system without a bell.</p><p>On top of that: the record is public in DNS and gets harvested by address
collectors. A private email address ends up permanently on spam lists.</p><p>Recommendation: leave it out and set only<code>issue</code>.</p></div><h3 id="a-production-grade-caddyfile">A production-grade Caddyfile</h3><p>The minimum works — for production operation, quite a bit is missing:</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>{
admin off
}
demo.svenruppert.com {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "SAMEORIGIN"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
-Server
}
reverse_proxy 127.0.0.1:8080 {
transport http {
read_timeout 0
write_timeout 0
}
}
log {
output file /var/log/caddy/demo.svenruppert.com.log {
roll_size 10MiB
roll_keep 10
}
format json
}
}</code></pre></div><p><code>-Server</code> removes the<code>Server: Jetty(12.1.12)</code> identifier from the response. No
client needs the exact version, but every scanner does — Chapter 4 shows
that these scanners actually do come by.</p><p><code>admin off</code> disables Caddy&rsquo;s administration interface on<code>127.0.0.1:2019</code>.
Through it, the entire configuration could be changed without authentication.
The price:<code>systemctl reload caddy</code> no longer works afterwards; changes
require a<code>restart</code>.</p><p>The timeouts in the<code>transport</code> block are the only item in this file that can
actively break Vaadin Push — Chapter 5 explains why.</p><h3 id="the-pitfall-when-validating">The pitfall when validating</h3><p>The obvious sequence is: validate first, then reload.</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 caddy validate --config /etc/caddy/Caddyfile<span class="c1"># "Valid configuration"</span></span></span><span class="line"><span class="cl">sudo systemctl reload caddy<span class="c1"># fails</span></span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>open /var/log/caddy/demo.svenruppert.com.log: permission denied</code></pre></div><p>The message is misleading, because the directory does belong to<code>caddy</code>. A
look at the file explains it:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ls -l /var/log/caddy/</span></span><span class="line"><span class="cl"><span class="c1"># -rw------- 1 root root 0 demo.svenruppert.com.log</span></span></span></code></pre></div></div><p><strong><code>caddy validate</code> instantiates the log writers and creates the file in the
process</strong> — under<code>sudo</code>, that means as<code>root</code> with<code>0600</code>. The service
running as<code>caddy</code> cannot subsequently open its own log file.</p><p>The fix: delete the file, reload. Or run the validation as the service account:</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 caddy caddy validate --config /etc/caddy/Caddyfile</span></span></code></pre></div></div><p>What is remarkable about the behavior: Caddy does<strong>not</strong> adopt a
configuration it cannot load — the old one kept running, the site stayed
reachable. Only a<code>restart</code> took the service down. With a reverse proxy, when
in doubt:<code>reload</code>, not<code>restart</code>.</p><h2 id="forwarded-headers-and-scheme-behind-caddy">Forwarded Headers and Scheme Behind Caddy</h2><p>The application is reachable, the certificate valid. Yet at this point the
installation is still wrong — and the mistake is invisible.</p><h3 id="what-the-application-sees">What the application sees</h3><p>Caddy accepts an HTTPS connection from a client somewhere on the internet
and establishes an<strong>unencrypted</strong> connection from the loopback address to
Jetty. From the application&rsquo;s point of view, every request therefore looks
like this:</p><ul><li>Scheme:<code>http</code>, not<code>https</code></li><li>Client address:<code>127.0.0.1</code></li><li>Port: 8080</li></ul><p>Three consequences follow, and none of them announces itself as an error:</p><p><strong>Absolute URLs are generated incorrectly.</strong> Wherever the application builds a
complete address — redirects, return addresses, links in emails — it contains<code>http://</code> and the internal port.</p><p><strong>The access log is worthless.</strong> Every request apparently comes from<code>127.0.0.1</code>.</p><p><strong>Session cookies lose their protection.</strong> A cookie with the<code>Secure</code> flag
is only transmitted over encrypted connections. If the application believes
the connection is unencrypted, it does not set the flag.</p><h3 id="making-the-error-visible">Making the error visible</h3><p>An access log in Jetty makes the state verifiable:</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="nb">cd</span> /opt/vaadinapp</span></span><span class="line"><span class="cl">sudo java -jar /opt/jetty/start.jar --add-modules<span class="o">=</span>requestlog</span></span><span class="line"><span class="cl">sudo nano start.d/requestlog.ini</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>jetty.requestlog.filePath=/var/lib/vaadinapp/logs/yyyy_mm_dd.request.log</code></pre></div><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 systemctl restart vaadinapp</span></span><span class="line"><span class="cl">curl -s https://demo.svenruppert.com/ &gt; /dev/null</span></span><span class="line"><span class="cl">sudo tail -1 /var/lib/vaadinapp/logs/*.request.log</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>127.0.0.1 - - [04/Sep/2026:09:56:48 +0000] "GET / HTTP/1.1" 200 198 "" "curl/8.7.1"</code></pre></div><p><strong>Every</strong> request — from the office next door or from Australia — appears in
the log as<code>127.0.0.1</code>.</p><h3 id="the-fix">The fix</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="nb">cd</span> /opt/vaadinapp</span></span><span class="line"><span class="cl">sudo java -jar /opt/jetty/start.jar --add-modules<span class="o">=</span>forwarded</span></span><span class="line"><span class="cl">sudo systemctl restart vaadinapp</span></span></code></pre></div></div><div class="pull-quote"><p><strong>The module name has changed.</strong> In Jetty 12.1 it is called<code>forwarded</code>.<code>http-forwarded</code> still exists but is explicitly marked as
deprecated — anyone following a guide written for Jetty 12.0 will pick the
old name.</p></div><p>The same request 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>80.187.114.255 - - [04/Sep/2026:09:57:11 +0000] "GET / HTTP/1.1" 200 198 "" "curl/8.7.1"</code></pre></div><p><strong>On the Caddy side there is nothing to do.</strong> Caddy sets<code>X-Forwarded-For</code>,<code>X-Forwarded-Proto</code>, and<code>X-Forwarded-Host</code>
with<code>reverse_proxy</code> on its own.<strong>No</strong> additional directive is needed —
all that is required is that Jetty actually evaluates these headers.</p><h3 id="the-proof-that-counts">The proof that counts</h3><p>The effect can be read directly from the real application&rsquo;s response:</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">curl -sI https://demo.svenruppert.com/<span class="p">|</span> grep -i set-cookie</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>set-cookie: JSESSIONID=node0pcg76yqw1hqd1btn7lkvwrmj66.node0; Path=/; Secure</code></pre></div><p>The<strong><code>Secure</code></strong> flag is set. So Jetty knows that the connection is
TLS-terminated — and that is achieved exclusively by this module. Without it,
the flag would be missing, and the session cookie would also travel over
unencrypted connections.</p><h3 id="the-trust-boundary">The trust boundary</h3><p>Forwarded headers are client-supplied claims. Whoever evaluates them believes
what the request says — a client could assert any address.</p><p>Here that is harmless,<strong>because Jetty listens exclusively on the loopback
address</strong> (Part 1, Chapter 8). Forged headers can only be sent by someone who
is already on the server; everything from outside inevitably passes through
Caddy, and Caddy sets the values itself.</p><p>Bind address and forwarded-header evaluation are therefore connected: the one
decision justifies the other. If you let Jetty listen on<code>0.0.0.0</code> while also
evaluating forwarded headers, you would have a log that anyone is allowed to write to.</p><h3 id="an-incidental-finding-that-was-not-planned">An incidental finding that was not planned</h3><p>A few minutes after the certificate was issued, external addresses appeared in
the log:</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>64.227.32.66 - - [...] "GET /config.json HTTP/1.1" 404 ... "l9scan/2.0 (+https://leakix.net)"
64.227.32.66 - - [...] "GET /telescope/requests HTTP/1.1" 404 ...
64.227.32.66 - - [...] "GET /info.php HTTP/1.1" 404 ...</code></pre></div><p>The certificate was issued at 09:55:17; the first external request arrived at
09:56:28 —<strong>one minute and eleven seconds later.</strong> After that, it came thick
and fast:</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>09:56:56 GET /console/
09:56:57 GET /server
09:56:58 GET /server-status
09:57:00 GET /about
09:57:01 GET /login.action
09:57:02 GET /___proxy_subdomain_whm/login
09:57:02 GET /v2/_catalog</code></pre></div><p>A cross-section of known vulnerabilities:<code>/login.action</code> looks for vulnerable
Confluence,<code>/v2/_catalog</code> for an open container registry,<code>/telescope/requests</code> for a Laravel diagnostic tool left in production,<code>/server-status</code> for an open Apache status page. In total,<strong>sixteen
different external addresses</strong> within a few minutes.</p><p>How did they know? From the<strong>Certificate Transparency logs.</strong> Every
issued TLS certificate is published in a public, machine-readable registry —
a security feature that makes misissuance detectable.
It has a side effect: anyone looking for new hostnames simply reads along. A
server is publicly known from the second its certificate is issued, not only
once someone passes the address around.</p><p>Together with the numbers from Part 0, Chapter 7 — the first login attempt
twenty-three seconds after boot — this adds up to a clear picture.</p><p><strong>And without this chapter, none of it would have been seen.</strong> Before the
module was activated, every log line read<code>127.0.0.1</code>. The accesses were there
the whole time, just invisible. A log that cannot distinguish the office next
door from a scanner network is not a log.</p><h2 id="vaadin-push-behind-caddy">Vaadin Push Behind Caddy</h2><p>Vaadin keeps the UI state on the server. Changes not triggered by a
user action — an incoming record, a progress update, a
notification — therefore need a path from the server to the browser. That path
is provided by Vaadin Push.</p><h3 id="websockets">WebSockets</h3><p>Technically, a push connection begins as an ordinary HTTP request asking for a
protocol upgrade:</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>GET /VAADIN/push?v-r=push HTTP/1.1
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13</code></pre></div><p>If the server answers with<code>101 Switching Protocols</code>, the connection stays open
and both sides send whenever they want. Exactly this upgrade is what an
intermediate reverse proxy has to pass through.</p><h3 id="on-the-jetty-side">On the Jetty side</h3><p>The required module was activated in Part 1, Chapter 7:</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>ee11-websocket-jakarta</code></pre></div><p>If it is missing, the upgrade fails, and Vaadin falls back to a technique that
polls the server at regular intervals. The application then still works — just
slower and with more load. That is the unpleasant kind of error: everything
looks fine.</p><h3 id="proxying--and-what-caddy-needs-for-it">Proxying — and what Caddy needs for it</h3><p>Nothing.</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>reverse_proxy 127.0.0.1:8080</code></pre></div><p>Caddy passes upgrade connections through transparently with<code>reverse_proxy</code>
on its own. There is no<code>proxy_http_version</code> setting, no<code>Upgrade</code> and<code>Connection</code> headers to set manually, no separate<code>location</code>
block for the push path.</p><p>That is worth mentioning because the widespread guides for other reverse
proxies contain exactly those lines — and because the official Vaadin
documentation on reverse proxies covers only Apache HTTPD and nginx. Caddy
does not appear there. Anyone copying from there will search for an equivalent
that does not exist, because it is not needed.</p><h3 id="upgrade-connections-and-timeouts">Upgrade connections and timeouts</h3><p>The only item in the Caddyfile that can actively break Push is time limits:</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>reverse_proxy 127.0.0.1:8080 {
transport http {
read_timeout 0
write_timeout 0
}
}</code></pre></div><p>A push connection is normally<strong>silent</strong>. It exists without data
flowing — until something happens. A read timeout of 30 seconds terminates it
after 30 seconds of quiet.</p><p>The error does not show up as an error: the application re-establishes the
connection, and everything appears normal. But updates occasionally fail to
arrive, and short-lived connections pile up in the log.<code>0</code> means no limit.</p><h3 id="proof">Proof</h3><p>The push endpoint responds:</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">curl -s -o /dev/null -w<span class="s1">'%{http_code}\n'</span><span class="se">\</span></span></span><span class="line"><span class="cl"><span class="s1">'https://demo.svenruppert.com/VAADIN/push?v-r=push'</span></span></span><span class="line"><span class="cl"><span class="c1"># 200</span></span></span></code></pre></div></div><p>More reliable is the observation in the browser: in the developer tools, the
network requests show a connection to<code>/VAADIN/push</code> with status<code>101</code> and type<code>websocket</code> that stays open.</p><p>If it does not stay open, or if instead of<code>101</code> an ordinary<code>200</code> response appears and repeats every few seconds, the upgrade has
failed — then either the Jetty module is missing or a time limit is kicking in.</p><p><strong>What this means for the following parts:</strong> The push connection is the most visible expression of what Chapter 10 deals with:
the server holds the state of this UI. If the service is restarted, not
only does the connection drop — the state behind it is gone.</p><h2 id="configuration-outside-the-war">Configuration Outside the WAR</h2><p>The artifact from Part 1, Chapter 4 is the same on every server. Whatever differs —
storage location, ports, addresses of neighboring systems — must therefore not
live inside it.</p><p>The reason is not aesthetics. An artifact that contains environment values has
to be rebuilt for every environment. Then the thing that was tested is not
the thing that is shipped.</p><h3 id="the-resolution-chain">The resolution chain</h3><p>The sample application resolves configuration values in three stages:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="n">String</span><span class="w"/><span class="nf">resolve</span><span class="p">(</span><span class="n">String</span><span class="w"/><span class="n">systemProperty</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">envVariable</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fallback</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fromSystem</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">System</span><span class="p">.</span><span class="na">getProperty</span><span class="p">(</span><span class="n">systemProperty</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">fromSystem</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="o">!</span><span class="n">fromSystem</span><span class="p">.</span><span class="na">isBlank</span><span class="p">())</span><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fromSystem</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fromEnv</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">System</span><span class="p">.</span><span class="na">getenv</span><span class="p">(</span><span class="n">envVariable</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">fromEnv</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="o">!</span><span class="n">fromEnv</span><span class="p">.</span><span class="na">isBlank</span><span class="p">())</span><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fromEnv</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fallback</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>A system property beats an environment variable; an environment variable beats
the default.</p><p>The order is deliberate: the system property is the path for the development
machine — it can be passed individually at startup without changing the
environment. The environment variable is the path for the server, because it
can be fed from a file maintained by someone other than the person performing
the start.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>app.host → APP_HOST → built-in default
app.port → APP_PORT → built-in default</code></pre></div><h3 id="environment">Environment</h3><p>On the server, these values come from a file that the unit definition
reads:</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">EnvironmentFile</span><span class="o">=</span><span class="s">-/etc/vaadinapp/environment</span></span></span></code></pre></div></div><p>The leading minus sign means: the file<strong>may be missing.</strong> Without it, the
application starts with its defaults instead of aborting with an error.</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 nano /etc/vaadinapp/environment</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>APP_HOST=127.0.0.1
APP_PORT=8080</code></pre></div><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 chown root:vaadinapp /etc/vaadinapp/environment</span></span><span class="line"><span class="cl">sudo chmod<span class="m">640</span> /etc/vaadinapp/environment</span></span></code></pre></div></div><p><code>root:vaadinapp</code> with<code>640</code>: the service may read, not write. After a
change, restarting the service is all it takes; the artifact remains untouched.</p><h3 id="separating-artifact-and-operational-data">Separating artifact and operational data</h3><p>That puts three things in three places, each with a different lifecycle:</p><table><thead><tr><th/><th>Location</th><th>changes</th></tr></thead><tbody><tr><td>Artifact</td><td><code>/opt/vaadinapp/webapps/ROOT.war</code></td><td>with every release</td></tr><tr><td>Configuration</td><td><code>/etc/vaadinapp/environment</code></td><td>when the environment changes</td></tr><tr><td>Data</td><td><code>/var/lib/vaadinapp/data</code></td><td>continuously during operation</td></tr></tbody></table><p>A release swaps out only the first row. Chapter 8 turns this into a
procedure.</p><h3 id="one-value-breaks-the-pattern">One value breaks the pattern</h3><p>The data location is not set through the environment file but in
the unit definition:</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>-Dapp.storage.dir=/var/lib/vaadinapp/data</code></pre></div><p>The reason is an irregularity in the application: this one key is evaluated
only as a system property, not additionally as an environment variable. A
line<code>APP_STORAGE_DIR=…</code> in the environment file would have no effect.</p><p>For operations, this makes no difference — the value simply lives in the
unit definition instead of the environment file, and both belong to<code>root</code>.
It is still worth mentioning, because an exception without a substantive
reason costs the reader more than it explains: anyone who has understood the
chain from the first section expects it everywhere.</p><p>Part 1, Chapter 11 showed what happens when this value is not set<strong>at all</strong>.</p><p><strong>What does not belong here:</strong> credentials. An environment variable appears in<code>/proc/&lt;PID&gt;/environ</code> and is
thus readable by anyone working as the same user. Chapter 7 covers
the difference.</p><h2 id="secrets-and-file-permissions">Secrets and File Permissions</h2><p>Configuration and credentials look similar and are therefore often treated the
same. They are not: a misplaced port is an operational error;
a misplaced access token is a security incident.</p><h3 id="two-principles">Two principles</h3><p><strong>No secrets in the WAR.</strong> The first principle is the simplest. An artifact travels across
development machines, version control, staging areas, and backups. Whatever is
inside it exists in many places at once.</p><p><strong>No secrets as process arguments.</strong> The second principle is better known than it is followed. It can be
demonstrated in 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"><span class="c1"># NOT like this:</span></span></span><span class="line"><span class="cl">java -Dapi.token<span class="o">=</span>s3cr3t -jar app.jar</span></span></code></pre></div></div><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"># From any other account on the same system:</span></span></span><span class="line"><span class="cl">ps aux<span class="p">|</span> grep api.token</span></span></code></pre></div></div><p>The process list is readable by every user on the system. That is not a
misconfiguration but normal behavior — process arguments are public.</p><h3 id="three-levels">Three levels</h3><p>This yields an ordering that leads from bad to good:</p><table><thead><tr><th/><th>Method</th><th>Who can get at it</th></tr></thead><tbody><tr><td>❌</td><td><code>-Dtoken=…</code> as an argument</td><td><strong>every</strong> user, via<code>ps</code></td></tr><tr><td>⚠️</td><td><code>EnvironmentFile</code></td><td>anyone working as the same user, via<code>/proc/&lt;PID&gt;/environ</code></td></tr><tr><td>✅</td><td><code>LoadCredentialEncrypted</code></td><td>only the service itself</td></tr></tbody></table><p>The middle level is where many stop — the file has<code>0640</code>, belongs to<code>root</code>, and that feels safe. The detour via<code>/proc</code> remains open nonetheless, and combined with<code>ptrace</code> access to the process memory all the more so. Part 0, Chapter 8 therefore
set the corresponding kernel parameter to the strict value.</p><h3 id="the-third-level">The third level</h3><p>systemd can store credentials encrypted and deliver them to the service without
them appearing as an environment variable or a readable file:</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="nb">printf</span><span class="s1">'%s'</span><span class="s2">"</span><span class="nv">$TOKEN</span><span class="s2">"</span><span class="p">|</span> sudo systemd-creds encrypt --name<span class="o">=</span>api-token -<span class="se">\</span></span></span><span class="line"><span class="cl"> /etc/vaadinapp/api-token.cred</span></span><span class="line"><span class="cl">sudo chmod<span class="m">600</span> /etc/vaadinapp/api-token.cred</span></span></code></pre></div></div><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="c1"># /etc/systemd/system/vaadinapp.service.d/20-credentials.conf</span></span></span><span class="line"><span class="cl"><span class="k">[Service]</span></span></span><span class="line"><span class="cl"><span class="na">LoadCredentialEncrypted</span><span class="o">=</span><span class="s">api-token:/etc/vaadinapp/api-token.cred</span></span></span></code></pre></div></div><p>The service finds the value at<code>$CREDENTIALS_DIRECTORY/api-token</code>.</p><h3 id="proof-1">Proof</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">ps aux<span class="p">|</span> grep -c<span class="s2">"</span><span class="nv">$TOKEN</span><span class="s2">"</span><span class="c1"># 0</span></span></span><span class="line"><span class="cl">tr<span class="s1">'\0'</span><span class="s1">'\n'</span> &lt; /proc/<span class="k">$(</span>systemctl show -p MainPID --value vaadinapp<span class="k">)</span>/environ<span class="se">\</span></span></span><span class="line"><span class="cl"><span class="p">|</span> grep -c<span class="s2">"</span><span class="nv">$TOKEN</span><span class="s2">"</span><span class="c1"># 0</span></span></span></code></pre></div></div><p>And the delivery itself:</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 -l /run/credentials/vaadinapp.service/</span></span><span class="line"><span class="cl">findmnt -no FSTYPE,OPTIONS /run/credentials/vaadinapp.service</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>-r--r-----+ 1 root root 28 api-token
tmpfs ro,nosuid,nodev,noexec,nosymfollow,size=1024k,mode=700,noswap</code></pre></div><p>A<strong>read-only tmpfs with<code>noexec</code>,<code>mode=700</code>, and<code>noswap</code>.</strong> The
value never touches the disk in plaintext and does not end up in the
swap file. A foreign account gets<code>Permission denied</code>.</p><h3 id="ownership-and-permissions">Ownership and permissions</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>/etc/vaadinapp/api-token.cred root:root 0600
/etc/vaadinapp/environment root:vaadinapp 0640
/opt/vaadinapp/webapps/ROOT.war root:vaadinapp 0640
/var/lib/vaadinapp/data vaadinapp:vaadinapp 0700</code></pre></div><p>Exactly one path belongs to the service, and it is the one it has to write to.</p><h3 id="a-caveat-that-belongs-here">A caveat that belongs here</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>Credential secret file '/var/lib/systemd/credential.secret'
is not located on encrypted media, using anyway.</code></pre></div><p>The virtual machine used here has no TPM. Without one, the
decryption key sits as a host key on the same disk. The protection
works against accidental disclosure, against plaintext backups, and against
the two paths from the proof above —<strong>not</strong> against someone who already has
root privileges on the running system.</p><p>That needs saying; otherwise the method promises more than it delivers.</p><h3 id="what-is-not-covered">What is not covered</h3><p>Rotating credentials during operation. A change here means:
encrypt a new file, restart the service — with the downtime from
Chapter 8. Automated rotation is a topic of its own.</p><p>And one final sentence that is missing too often: whoever logs an access
token has it in the journal. All the care in delivery is then wasted.</p><h2 id="manual-update">Manual Update</h2><h3 id="keeping-releases-side-by-side">Keeping releases side by side</h3><p>A deployment that overwrites the old file knows no way back. That is why every
release gets its own 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>/opt/vaadinapp/
├── releases/
│ ├── 2026-09-04-1200/ROOT.war
│ └── 2026-09-07-1442/ROOT.war
└── webapps/
└── ROOT.war ← the active version</code></pre></div><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 archive belongs to<code>root:vaadinapp</code>. The service may read, not write —
anyone who takes over the application can alter neither the active artifact
nor its predecessors.</p><h3 id="staging-the-new-war">Staging the new WAR</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 clean package</span></span><span class="line"><span class="cl">scp target/ROOT.war sven@demo.svenruppert.com:/home/sven/</span></span></code></pre></div></div><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 +%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 install -m<span class="m">640</span> -o root -g vaadinapp /home/sven/ROOT.war<span class="se">\</span></span></span><span class="line"><span class="cl"> /opt/vaadinapp/releases/<span class="nv">$STAMP</span>/</span></span><span class="line"><span class="cl">rm /home/sven/ROOT.war</span></span></code></pre></div></div><p>Up to this point, nothing has happened. The running application is untouched;
the new release is merely staged.</p><h3 id="stop-the-service-swap-the-deployment-start-the-service">Stop the service, swap the deployment, start the service</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">sudo systemctl stop vaadinapp</span></span><span class="line"><span class="cl">sudo install -m<span class="m">640</span> -o root -g vaadinapp<span class="se">\</span></span></span><span class="line"><span class="cl"> /opt/vaadinapp/releases/<span class="nv">$STAMP</span>/ROOT.war /opt/vaadinapp/webapps/ROOT.war</span></span><span class="line"><span class="cl">sudo systemctl start vaadinapp</span></span></code></pre></div></div><p>Three commands.<code>install</code> instead of<code>cp</code>, for the same reason as in Part 1, Chapter 9.</p><h3 id="why-no-swap-during-operation">Why no swap during operation</h3><p>Jetty&rsquo;s monitoring of the<code>webapps</code> directory could detect a changed file on
its own and redeploy it. That is disabled here, and for good
reason: a swap during operation means that two versions of the
application briefly exist at the same time — with the same data store.</p><p>The explicit stop is more honest. It costs downtime, but the state is
unambiguous at every point in time.</p><h3 id="what-it-costs">What it costs</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">sudo systemctl restart vaadinapp</span></span><span class="line"><span class="cl"><span class="c1"># Measure the time until the first successful response</span></span></span></code></pre></div></div><table><thead><tr><th/><th>Value</th></tr></thead><tbody><tr><td>Artifact size</td><td>21 MB</td></tr><tr><td>Downtime until the first response</td><td><strong>around 5.7 seconds</strong></td></tr><tr><td>of which Jetty startup, per the log</td><td>4.1 seconds</td></tr><tr><td>Memory footprint at idle</td><td>461 MiB of 1,536 MiB</td></tr><tr><td>Threads</td><td>33</td></tr></tbody></table><p>The downtime consists of three components: starting the JVM,
unpacking the archive to<code>java.io.tmpdir</code>, and starting the application
itself — for this application including opening its data store.</p><p>The values come from the run with the slimmed-down archive from Part 1, Chapter 4,
measured on 2026-09-07 at release<code>2026-09-07-1627</code>. The
comparison with the previous state is instructive, because it refutes a
widespread expectation:</p><table><thead><tr><th/><th>28 MB, 126 libraries</th><th>21 MB, 86 libraries</th></tr></thead><tbody><tr><td>Downtime</td><td>6.3 s</td><td>5.7 s</td></tr><tr><td>Jetty startup</td><td>4.2 s</td><td>4.1 s</td></tr><tr><td>Memory at idle</td><td>445 MiB</td><td>461 MiB</td></tr></tbody></table><p>A quarter less archive shortens the startup by a good half second — that
is the share contributed by unpacking and by scanning the libraries for
annotations.<strong>Memory consumption, however, does not drop</strong>; within
measurement noise it is even slightly higher. That is no contradiction: what
an archive weighs says little about how many classes the application actually
loads at runtime. What was removed were libraries that were never going to be
loaded anyway. Shrinking a WAR buys transfer time, disk space, and
clarity — not RAM.</p><p>For comparison: a minimal web application without its own data store was
reachable on the same server after around 2 seconds. The difference is not Jetty; it is the
application.</p><p>These numbers are the baseline for the parts that follow. It will be
interesting to see whether embedded Jetty (Part 3) and the custom runtime from
Part 6 measurably shorten the startup.</p><p><strong>Four steps that could be automated:</strong> build, transfer, swap, restart. Exactly these four steps are what a
deployment pipeline replaces — nothing more. Anyone who has run them by hand once
knows afterwards where an automation can fail.</p><h2 id="rollback">Rollback</h2><h3 id="keeping-the-previous-artifact">Keeping the previous artifact</h3><p>The way back is only possible because Chapter 8 kept the old releases.
Without the archive there would be no rollback, only a fresh build from a
hopefully matching state of version control — under time pressure, in
the middle of an incident.</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 -1 /opt/vaadinapp/releases/</span></span></code></pre></div></div><p>How many releases to keep is a question of disk space. At
21 MB per artifact, ten releases are 210 MB — with 75 GB of
disk space, not a serious consideration.</p><h3 id="a-controlled-rollback">A controlled rollback</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">sudo systemctl stop vaadinapp</span></span><span class="line"><span class="cl">sudo install -m<span class="m">640</span> -o root -g vaadinapp<span class="se">\</span></span></span><span class="line"><span class="cl"> /opt/vaadinapp/releases/2026-09-04-1200/ROOT.war /opt/vaadinapp/webapps/ROOT.war</span></span><span class="line"><span class="cl">sudo systemctl start vaadinapp</span></span></code></pre></div></div><p>The same procedure as in Chapter 8, just with an older timestamp.</p><h3 id="a-rollback-costs-exactly-as-much-as-a-deployment">A rollback costs exactly as much as a deployment</h3><p>That is the most important statement in this chapter, and it surprises many.</p><p>Both operations consist of the same three commands and the same downtime —
around six seconds. There is<strong>no technical penalty for backing out.</strong></p><p>Anyone hesitating because a rollback is supposedly costly hesitates without
reason. What is costly is not the way back but the decision: recognizing that
something is wrong, and making the call.</p><p>Which release is currently active can be determined at any time:</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">sha256sum /opt/vaadinapp/webapps/ROOT.war</span></span><span class="line"><span class="cl">sha256sum /opt/vaadinapp/releases/*/ROOT.war</span></span></code></pre></div></div><h3 id="the-limit-the-data-does-not-come-back">The limit: the data does not come back</h3><p>Here the symmetry ends, and this is the point that deployment guides like to
leave out.</p><p>The rollback swaps<strong>only the artifact.</strong> The data under<code>/var/lib/vaadinapp/data</code> stays as it is — it is not part of the release
and survives every swap.</p><p>As long as a new application version only reads and writes what the old one
also understood, this is unproblematic. As soon as it changes the storage
format — an additional field, a changed structure, a migration at startup —
this no longer holds: the old version then encounters data it does not expect.</p><p>Two operational rules follow:</p><p><strong>Before a release that changes the data, a backup belongs in
place.</strong> A rollback is then artifact<strong>and</strong> data.</p><p><strong>Whoever runs a migration at startup should know whether it is reversible.</strong>
If it is not, there is no rollback anymore, only rolling forward —
and that requires a working new version.</p><p>For the sample application with its embedded store, this means concretely: a
rollback across a format change requires the backup of the
data directory. Without it, the way back stays open exactly as long as the
storage format remains unchanged.</p><h2 id="vaadin-sessions-and-restarts">Vaadin Sessions and Restarts</h2><p>Chapter 8 put a number on the downtime: around six seconds. For a
stateless application, that would be the whole story. For a Vaadin application,
it is the smaller part.</p><h3 id="server-side-ui-state">Server-side UI state</h3><p>Vaadin keeps the state of the UI on the server. Which view is open,
what a form contains, which row of a table is selected —
all of that lives in the session, not in the browser. The browser renders and
reports events back.</p><p>That is the property that defines Vaadin: application logic in Java, without
separate state management in the frontend. It has a price, and the price comes
due at every restart.</p><h3 id="loss-of-running-sessions">Loss of running sessions</h3><p>A restart terminates the process. With it, the entire session state
disappears.</p><p>That is<strong>not a flaw of this deployment.</strong> It follows directly from the
architecture: whoever keeps state in the server loses it with the server. A
stateless REST backend loses nothing on a restart, because it has nothing to
lose.</p><p>The difference deserves naming, because it shapes expectations. The
sample application with login and roles makes it tangible: what is affected is
not a counter but a logged-in session in the middle of work.</p><h3 id="impact-on-users">Impact on users</h3><p>Concretely, it looks like this: the page reloads, the login is gone, unsaved
input is lost. With Push active (Chapter 5), the open connection additionally
drops, and the browser tries to re-establish it.</p><p>The stored data is unaffected — whatever was saved lives under<code>/var/lib/vaadinapp/data</code> and survives. What is lost is exclusively what had
not yet been saved.</p><h3 id="session-persistence--and-why-it-is-not-used-here">Session persistence — and why it is not used here</h3><p>Jetty can serialize sessions and carry them across a restart. The
prerequisite is that the<strong>entire</strong> session content is serializable.</p><p>With Vaadin, that is a well-known sore spot. The session content is the
component tree of the UI along with all registered listeners. A single lambda
capturing a non-serializable reference is enough to make the
serialization fail — and not during development, but at
restart in production.</p><p>It is doable, but it demands discipline across the entire application code and
a test that covers this case. For this series, it is therefore named and
not done.</p><h3 id="maintenance-windows">Maintenance windows</h3><p>The pragmatic answer with one server and manual deployment: restarts are
scheduled, not endured.</p><p>Remarkably, this decision was already made in Part 0. Whoever enabled<code>unattended-upgrades</code> with automatic reboot there has fixed their
maintenance window — namely 3:30 in the morning. Whoever did not enable it
has unpatched kernels. Both are defensible; staying undecided
is not.</p><h3 id="where-this-series-draws-a-line">Where this series draws a line</h3><p>Two instances behind Caddy, updated alternately, would defuse the
problem: while one restarts, the other serves. That is an
established technique and particularly attractive for a Vaadin application,
because it limits the session loss to half the users — provided the proxy
keeps sessions on the same instance.</p><p>However, it breaks the constraints from Part 1, Chapter 2: one server, no
orchestration. That is why it stands here as a boundary and not as a guide.</p><h3 id="why-this-chapter-is-in-the-title">Why this chapter is in the title</h3><p>Alongside Push from Chapter 5, this is the second place where a property<strong>of Vaadin</strong> shapes the deployment — and the only one that changes the
operating procedure itself. Everything else — Jetty, systemd, Caddy,
certificates, hardening — would apply equally to any Java web application.</p><h2 id="result">Result</h2><p>A Vaadin application is reachable under its own domain name over HTTPS.
Four building blocks across two parts, each set up individually, each
individually verifiable.</p><h3 id="the-last-step-setting-up-the-application">The last step: setting up the application</h3><p>Deployed does not yet mean usable. The sample application ships with login and
roles and therefore needs an initial administrator account — no
deployment can deliver that, because it would contain a password.</p><p>On first start, the application instead writes a one-time token into its
data directory:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo cat /var/lib/vaadinapp/data/jcustos/bootstrap.token</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>token=XXXX-XXXX-XXXX-XXXX-XXXX
createdAt=2026-09-07T12:44:10Z</code></pre></div><p>In the browser, the application then walks through the setup: token, username,
password. After that, the file is used up.</p><p><strong>The procedure is more remarkable than it looks.</strong> The token exists
exclusively on the server, readable only by the service account and by<code>root</code>.
Whoever finds the application on the net before it is set up cannot complete
the setup — they would need access to the file system for that. Given the
scanners from Chapter 4, which come by within a minute, that is no
theoretical advantage: a setup page without such protection would be an
open door for exactly the span between certificate and first login.</p><p>This protection is a property of the application, not of the deployment. It
belongs here because it makes the difference between a reachable and an
operational installation — and because everyone following this guide will
look for it at this point.</p><h3 id="what-is-running-now">What is running now</h3><table><thead><tr><th>Building block</th><th>State</th></tr></thead><tbody><tr><td>Caddy</td><td>public endpoint, TLS, automatic certificates, hardened (1.5)</td></tr><tr><td>Jetty 12.1.12</td><td>servlet container,<code>ee11</code>, only on<code>127.0.0.1:8080</code></td></tr><tr><td><code>ROOT.war</code></td><td>21 MB, production build, served at<code>/</code></td></tr><tr><td>systemd</td><td>service starts at boot, confined (1.1), logs to journald</td></tr><tr><td>Temurin JDK 26</td><td>via SDKMAN, no system-wide Java</td></tr></tbody></table><h3 id="the-numbers">The numbers</h3><table><thead><tr><th/><th/></tr></thead><tbody><tr><td>Downtime for restart, deployment, and rollback</td><td>around 5.7 seconds</td></tr><tr><td>of which Jetty startup</td><td>4.1 seconds</td></tr><tr><td>Memory footprint at idle</td><td>461 MiB of 1,536 MiB</td></tr><tr><td>Threads</td><td>33</td></tr><tr><td>Steps per release</td><td>4</td></tr><tr><td>Hardening score<code>vaadinapp</code></td><td>from 9.2 to 1.1</td></tr><tr><td>Hardening score<code>caddy</code></td><td>from 8.8 to 1.5</td></tr></tbody></table><p>These values are the yardstick that Parts 3 through 5 will have to measure up
against.</p><div class="pull-quote"><p><strong>Measurement status:</strong> All values in this part were taken on 2026-09-07 on the reference server, release<code>2026-09-07-1627</code>. From Part 3 on, the same server runs the
embedded setup. For the comparison in Part 3, the state described here was
measured once more in full immediately before the switch —
with three restarts instead of one deployment, which explains the small
deviations.</p></div><h3 id="what-became-visible-along-the-way">What became visible along the way</h3><p>Four observations carry beyond this part.</p><p><strong>Individual measures only work in combination, even across parts.</strong> The
bind address from Part 1, Chapter 8 justifies the forwarded-header evaluation
from Chapter 4 — without the one, the other would be either ineffective or
dangerous. The kernel parameter from Part 0, Chapter 8 closes the gap that
Chapter 7 leaves open. None of these measures stands on its own, and none of
them sits where you would look for it first.</p><p><strong>Two services, two sets of directives.</strong> Caddy and the application run on the
same server and still needed different hardening: what breaks the JVM does not
bother a program written in Go, and vice versa. Copying a template from one to
the other would have damaged both — one rendered inoperative, the other left
needlessly exposed.</p><p><strong>The certificate is a starting gun.</strong> One minute passed between the issuance
and the first scanner probing the new name. A domain name in the
certificate log is public the moment it comes into existence. Anyone who only
starts thinking about hardening afterwards is thinking too late — which is why
the service stood hardened at the end of Part 1 before anyone could reach it
at all.</p><p><strong>A rollback costs nothing.</strong> The same three commands, the same downtime.
What is expensive is not the way back but the decision — and the data, which
does not come along.</p><h3 id="what-this-setup-does-not-deliver">What this setup does not deliver</h3><p>For honesty&rsquo;s sake, because the following parts pick up exactly here:</p><p><strong>There is downtime.</strong> Around six seconds per release, and all running
sessions are lost in the process.</p><p><strong>Four things have to be maintained separately:</strong> operating system, JDK, Jetty, and
application. Each has its own update cycle, and only one of them is
in the application developer&rsquo;s hands.</p><p><strong>The artifact is incomplete.</strong> A WAR does not run on its own; it needs a
container of the right version with the right modules. What works on the
development machine can fail on a differently configured server.</p><p>Part 3 starts at exactly this last point.</p><h2 id="outlook-on-part-3">Outlook on Part 3</h2><h3 id="the-boundary-shifts">The boundary shifts</h3><p>Part 1, Chapter 1 named the through line: in Parts 1 and 2, the
application consists exclusively of the WAR. Jetty, the Java runtime, and the server are
environment — present, maintained, updated independently.</p><p>Part 3 shifts this boundary for the first time.<strong>Jetty becomes a library
of the application.</strong></p><h3 id="what-changes-as-a-result">What changes as a result</h3><p>Instead of an installed servlet container into which an archive is placed,
there is an application with its own<code>main</code> method. It creates the server,
configures the connector, mounts the Vaadin servlet, and starts.</p><p>Concretely, several things from Part 1 disappear:</p><table><thead><tr><th>from Part 1</th><th>in Part 3</th></tr></thead><tbody><tr><td><code>/opt/jetty</code> as an installation</td><td>a Maven dependency</td></tr><tr><td><code>JETTY_HOME</code> and<code>JETTY_BASE</code></td><td>gone</td></tr><tr><td><code>--add-modules=…</code></td><td>dependencies in<code>pom.xml</code></td></tr><tr><td><code>start.d/http.ini</code></td><td>Java code in the startup sequence</td></tr><tr><td><code>webapps/ROOT.war</code></td><td>an executable artifact</td></tr></tbody></table><h3 id="what-stays">What stays</h3><p>More remarkable than the differences is how little changes. Caddy remains
unchanged — it talks to<code>127.0.0.1:8080</code> and does not know what runs behind
it. The unit definition from Part 1, Chapter 10 essentially changes its<code>ExecStart</code> line. The directory layout, the service account, the hardening, the
configuration, and the credentials stay as they are.</p><p>That is no coincidence but the result of the separation that Part 0 and
Chapters 9 through 11 of Part 1 built up: the operational frame is independent of how
the application starts internally.</p><h3 id="what-is-gained-and-what-is-given-up">What is gained and what is given up</h3><p><strong>Gained</strong> is an artifact that is more complete. The Jetty version becomes a
decision of the application project and moves into version control — a
server with a misconfigured container can no longer damage the
application.</p><p><strong>Given up</strong> is independence. From Part 3 on, a security update for Jetty is
an application release: rebuild, redeliver, restart. In Part 1,
it was a symlink and a restart, without touching the application.</p><p>Which of the two splits is the right one depends on who maintains the server
and how often releases ship. Part 6 puts all five variants side by side at
the end — with the numbers from Chapter 11 as the starting point.</p>
]]></content:encoded><category>Java</category><category>Vaadin</category><media:content url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-2-publicly-reachable-with-caddy-and-https-hero.png" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-2-publicly-reachable-with-caddy-and-https-hero.png"/><enclosure url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-2-publicly-reachable-with-caddy-and-https-hero.png" type="image/jpeg" length="0"/></item><item><title>Vaadin Deployment on Hetzner — Part 3: Embedded Jetty</title><link>https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-3-embedded-jetty/</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-3-embedded-jetty/</guid><description>Embedded Jetty turns the server into part of the application: a Maven reactor, one module per delivery form, and a clean bootstrap without an installed Jetty.</description><content:encoded>&lt;![CDATA[<p>Third installment of the series<em>Vaadin – Deployment on Hetzner</em>. Jetty turns from a separately installed server component into a part of the application: its own bootstrap, its own classpath, no module mechanism anymore. All commands, outputs, and measurements come from an actual run on a Debian 13 server.</p><h2 id="starting-point">Starting Point</h2><p>At the end of Part 2, a Vaadin application runs as<code>ROOT.war</code> in a Jetty
12.1.12 installed under<code>/opt/jetty</code>, reachable over HTTPS behind Caddy,
as a hardened systemd service. Two things are maintained separately there:
the application and the server it runs in.</p><p>This part removes that separation. Afterwards, the application brings its
server along itself — as a dependency, not as an installation.</p><h3 id="what-shifts-as-a-result">What Shifts as a Result</h3><figure><img src="/images/2026/09/vaadin-deployment-on-hetzner-part-3-embedded-jetty-teil03-eingebettet.png" alt="The server becomes part of the application" loading="lazy" decoding="async"/><p><em>Figure 1: The dashed boundary now includes the server as well.</em></p><p>Part 1, Chapter 1 named the connecting thread of this series: the dashed
boundary around what belongs to the application. In Parts 1 and 2, only the
WAR lay behind this boundary. Now Jetty moves inside.</p><table><thead><tr><th/><th>Parts 1 and 2</th><th>from here on</th></tr></thead><tbody><tr><td>Jetty</td><td>installation under<code>/opt/jetty</code></td><td>Maven dependency</td></tr><tr><td>Startup</td><td><code>java -jar /opt/jetty/start.jar</code></td><td>its own<code>main()</code> method</td></tr><tr><td>Configuration</td><td><code>start.d/*.ini</code></td><td>Java code</td></tr><tr><td>Artifact</td><td><code>ROOT.war</code></td><td>application archive plus<code>lib/</code></td></tr><tr><td>Jetty version determined by</td><td>the server</td><td>the project</td></tr></tbody></table><p>The last row is the actual point. Anyone who wants to switch the Jetty
version afterwards changes a number in the<code>pom.xml</code> and ships the
application again — no longer a symlink on the server.</p><h3 id="the-repository-becomes-a-reactor">The Repository Becomes a Reactor</h3><p>Up to this point, the demo application was<strong>one</strong> Maven module. That worked
as long as there was one delivery format. With the second one, it no longer
worked well: the starter for embedded operation lived in<code>src/main/java</code> and
was therefore compiled with every build — including the WAR build, where
nobody calls it. This is exactly where the finding from Part 1, Section 4.5
came from: the complete Jetty ended up in the WAR because a single
dependency stood there without a<code>&lt;scope&gt;</code>.</p><p>The remedy back then was a declaration. The cause only disappears now:</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>Blog-Vaadin-Deployment-on-Hetzner/ reactor
├── core/ the application, the tests, the frontend bundle
├── war-jetty/ ROOT.war for the external Jetty (parts 1 and 2)
└── embedded-jetty/ own main() (this part)</code></pre></div><p>One module per delivery format. The WAR module contains<strong>not a single line
of Java source code</strong> — only a<code>web.xml</code>. The embedded module contains exactly
one class. Everything else lives in the core and is used by both.</p><div class="pull-quote"><p><strong>About the names:</strong> The directories are named after the delivery format,
not after the part number — the WAR module belongs to<strong>two</strong> parts. The<code>artifactId</code>s additionally carry the prefix<code>vaadinapp-</code>, because a module
named<code>core</code> would collide with<code>com.svenruppert:core</code>, a dependency of
this application. A detail that is easy to overlook when splitting up a
project, and one the build acknowledges with an incomprehensible message.</p></div><h3 id="the-code-for-this-part">The Code for This Part</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">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-03</span></span></code></pre></div></div><p>The state of Parts 1 and 2 — one module, building to<code>target/ROOT.war</code> —
remains available on the tag<code>teil-02</code>. A<code>git diff teil-02 teil-03</code> shows
exactly what this part describes.</p><h3 id="what-this-part-does-not-cover">What This Part Does Not Cover</h3><p><strong>Packaging.</strong> At the end of this part, an application archive sits next to
a directory containing its libraries. Whether that becomes a single archive
or a tidy directory structure is the question for Part 4 — and it can only
be asked meaningfully once the unpackaged case has been seen standing on its
own.</p><h2 id="what-does-embedded-jetty-mean">What Does Embedded Jetty Mean?</h2><p>The term sounds like a variant of the same thing. In fact, it describes a
reversal of responsibility.</p><h3 id="a-library-instead-of-a-server-installation">A Library Instead of a Server Installation</h3><p>In the setup so far, Jetty is a program. It lives under<code>/opt/jetty</code>, it has
its own directory structure, it is configured through files, and it starts
the application. The application is an archive this program reads in.</p><p>Embedded, Jetty is a library. It sits on the classpath next to Jackson and
SLF4J, it has no directory structure of its own, it is configured through
Java code, and<strong>the application starts it</strong>. The program that loads the
application becomes a dependency the application uses.</p><h3 id="application-lifecycle-instead-of-container-lifecycle">Application Lifecycle Instead of Container Lifecycle</h3><p>The practical difference shows in the startup sequence.</p><p>With the external Jetty, systemd starts the container, the container looks
for archives in<code>webapps/</code>, unpacks them, builds one web application context
each, and calls the initializers inside. The application has no entry point;
it gets found.</p><p>Embedded, systemd starts<strong>the application</strong>. Its<code>main()</code> method creates a
server, mounts a context, starts it, and waits. The entry point is ordinary
Java code that a narrative can walk along — and that is exactly what
Chapters 4 through 9 do.</p><h3 id="what-becomes-visible-in-the-process">What Becomes Visible in the Process</h3><p>The external Jetty takes care of a whole series of things without any of
them showing up in the project. The module files under<code>start.d/</code> numbered
sixteen in the reference setup. Sixteen decisions that someone once made and
that nobody sees afterwards.</p><p>Embedded, every one of them that is actually needed must appear in the code.
That is more work — and it is the reason this part is instructive in the
first place: what used to be configuration becomes readable.</p><p>Three of these decisions fail<strong>silently</strong> if you forget them. They get a
chapter of their own in Chapter 10, because they make the difference between
&ldquo;runs&rdquo; and &ldquo;runs correctly&rdquo;.</p><h3 id="what-stays-the-same">What Stays the Same</h3><p>More remarkable than the differences is how little changes in operation.
Service account, directory layout, hardening, configuration outside the
artifact, credentials via<code>systemd-creds</code>, Caddy in front — all unchanged.
The unit file swaps one line.</p><p>That is no coincidence but the result of the separation that Part 0 and
Chapters 9 through 11 of Part 1 built up: the operational framework does not
know<strong>how</strong> the application starts internally, and it does not need to.</p><h2 id="maven-dependencies">Maven Dependencies</h2><p>The external Jetty was set up with<code>--add-modules=server,http,ee11-deploy,ee11-annotations,ee11-websocket-jakarta</code>.
Embedded, there are no modules — there are dependencies. Translating one
into the other is the subject of this chapter.</p><h3 id="four-entries">Four Entries</h3><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;dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>org.eclipse.jetty<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>jetty-server<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>org.eclipse.jetty.ee11<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>jetty-ee11-webapp<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>org.eclipse.jetty.ee11<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>jetty-ee11-annotations<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>org.eclipse.jetty.ee11.websocket<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>jetty-ee11-websocket-jakarta-server<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/dependency&gt;</span></span></span></code></pre></div></div><table><thead><tr><th>Dependency</th><th>replaces the module</th><th>used for</th></tr></thead><tbody><tr><td><code>jetty-server</code></td><td><code>server</code>,<code>http</code></td><td><code>Server</code>,<code>ServerConnector</code>,<code>HttpConfiguration</code></td></tr><tr><td><code>jetty-ee11-webapp</code></td><td><code>ee11-deploy</code></td><td><code>WebAppContext</code></td></tr><tr><td><code>jetty-ee11-annotations</code></td><td><code>ee11-annotations</code></td><td>the scanner that finds Vaadin&rsquo;s routes</td></tr><tr><td><code>jetty-ee11-websocket-jakarta-server</code></td><td><code>ee11-websocket-jakarta</code></td><td>Vaadin Push</td></tr></tbody></table><p>No version numbers — those come from the two BOMs that were already imported
in Part 1.<code>${jetty.version}</code> thus remains the only place where the Jetty
release is recorded, now for the build instead of for the installation.</p><h3 id="the-servlet-api-switches-sides">The Servlet API Switches Sides</h3><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;dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;groupId&gt;</span>jakarta.servlet<span class="nt">&lt;/groupId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;artifactId&gt;</span>jakarta.servlet-api<span class="nt">&lt;/artifactId&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;scope&gt;</span>compile<span class="nt">&lt;/scope&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/dependency&gt;</span></span></span></code></pre></div></div><p>In the WAR module, this says<code>provided</code>: available for compilation, left out
at packaging time because the container supplies it. Here there is no
container supplying anything — the application<strong>is</strong> one. So the API has to
come along.</p><p>That is the same reasoning as in Part 1, Section 4.5, only applied in the
opposite direction. Anyone who reads<code>provided</code> as &ldquo;does not belong in the
archive&rdquo; has misunderstood it; it means &ldquo;someone else provides this&rdquo;. When
there is no someone else, the answer changes.</p><h3 id="four-entries-nobody-ordered">Four Entries Nobody Ordered</h3><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;dependency&gt;&lt;groupId&gt;</span>org.ow2.asm<span class="nt">&lt;/groupId&gt;&lt;artifactId&gt;</span>asm<span class="nt">&lt;/artifactId&gt;&lt;/dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;dependency&gt;&lt;groupId&gt;</span>org.ow2.asm<span class="nt">&lt;/groupId&gt;&lt;artifactId&gt;</span>asm-tree<span class="nt">&lt;/artifactId&gt;&lt;/dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;dependency&gt;&lt;groupId&gt;</span>org.ow2.asm<span class="nt">&lt;/groupId&gt;&lt;artifactId&gt;</span>asm-commons<span class="nt">&lt;/artifactId&gt;&lt;/dependency&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;dependency&gt;&lt;groupId&gt;</span>org.ow2.asm<span class="nt">&lt;/groupId&gt;&lt;artifactId&gt;</span>asm-analysis<span class="nt">&lt;/artifactId&gt;&lt;/dependency&gt;</span></span></span></code></pre></div></div><p>ASM is the library Jetty&rsquo;s annotation scanner uses to read class files. It
appears here only to force<strong>a single version</strong>. Default resolution mixes<code>asm</code> 9.8 (via<code>j2objc-annotations</code>) with<code>asm-tree</code> and<code>asm-commons</code> 9.9.1
(via Jetty). This mixture breaks on Java 26 bytecode:</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.lang.IllegalArgumentException: Unsupported class file major version 70</code></pre></div><p>The context then fails to start, and the message mentions neither Jetty nor
Vaadin. It is included here because without prior knowledge it is almost
impossible to place: class file version 70 is Java 26.</p><h3 id="what-disappears">What Disappears</h3><p>One dependency is removed from the core module without replacement — the
library that encapsulated the embedded startup until now. With it goes the<code>&lt;scope&gt;provided&lt;/scope&gt;</code> workaround from Part 1, Section 4.5, and the whole
profile machinery that squeezed an additional archive out of a WAR module.</p><p>The reason this works: the embedded module has<code>jar</code> packaging from the
start. Nothing needs to be worked around when the structure matches the
intent.</p><h2 id="the-applications-entry-point">The Application&rsquo;s Entry Point</h2><p>A web application with a<code>main()</code> method looks unusual at first. It is the
core of this part.</p><h3 id="the-class">The Class</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">public</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="kd">class</span><span class="nc">Application</span><span class="w"/><span class="kd">implements</span><span class="w"/><span class="n">HasLogger</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">private</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">DEFAULT_HOST</span><span class="w"/><span class="o">=</span><span class="w"/><span class="s">"127.0.0.1"</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">private</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="kt">int</span><span class="w"/><span class="n">DEFAULT_PORT</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">8080</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">public</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="kt">void</span><span class="w"/><span class="nf">main</span><span class="p">(</span><span class="n">String</span><span class="o">[]</span><span class="w"/><span class="n">args</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">new</span><span class="w"/><span class="n">Application</span><span class="p">().</span><span class="na">launch</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="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="err">…</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>It is the<strong>only</strong> class in the<code>embedded-jetty</code> module. Everything else —
views, security, persistence, translations — lives in the core and is pulled
in via a dependency. That is not frugality but the statement the split
makes: how the application starts is a question of the delivery format, and
nothing else.</p><h3 id="host-and-port">Host and Port</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kt">void</span><span class="w"/><span class="nf">launch</span><span class="p">()</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">String</span><span class="w"/><span class="n">host</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">resolve</span><span class="p">(</span><span class="s">"app.host"</span><span class="p">,</span><span class="w"/><span class="s">"APP_HOST"</span><span class="p">,</span><span class="w"/><span class="n">DEFAULT_HOST</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kt">int</span><span class="w"/><span class="n">port</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">resolvePort</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="err">…</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="n">String</span><span class="w"/><span class="nf">resolve</span><span class="p">(</span><span class="n">String</span><span class="w"/><span class="n">systemProperty</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">envVariable</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fallback</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fromSystem</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">System</span><span class="p">.</span><span class="na">getProperty</span><span class="p">(</span><span class="n">systemProperty</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">fromSystem</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="o">!</span><span class="n">fromSystem</span><span class="p">.</span><span class="na">isBlank</span><span class="p">())</span><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fromSystem</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fromEnv</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">System</span><span class="p">.</span><span class="na">getenv</span><span class="p">(</span><span class="n">envVariable</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">fromEnv</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="o">!</span><span class="n">fromEnv</span><span class="p">.</span><span class="na">isBlank</span><span class="p">())</span><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fromEnv</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fallback</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>Three sources in a fixed order: system property, environment variable,
default value. The external Jetty made the same decision in<code>start.d/http.ini</code>; here it stands as a method you can read.</p><p>The default is<code>127.0.0.1</code>, not<code>0.0.0.0</code>. That is the same decision as in
Part 1, Chapter 8, and here it matters even more: a typo in a configuration
file gets caught during review, a wrong default in the code takes effect
everywhere nobody configured anything.</p><h3 id="the-check-that-comes-before-everything-else">The Check That Comes Before Everything Else</h3><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="k">if</span><span class="w"/><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="na">getResource</span><span class="p">(</span><span class="n">BUNDLE_MARKER</span><span class="p">)</span><span class="w"/><span class="o">==</span><span class="w"/><span class="kc">null</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">abort</span><span class="p">(</span><span class="s">"Vaadin production bundle missing from the classpath (expected "</span><span class="w"/><span class="o">+</span><span class="w"/><span class="n">BUNDLE_MARKER</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="o">+</span><span class="w"/><span class="s">"). Build with `mvn -Pproduction package` before starting; without it the "</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="o">+</span><span class="w"/><span class="s">"server starts, answers 200 and serves a blank page."</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">return</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>Five lines that catch one of the most annoying failure modes. Chapter 8
describes it in detail; the short version: if the built frontend is missing,
the server starts anyway, answers with 200, and serves a white page. The
error then shows up not in the server log but in the browser console — and
anyone who does not look there searches in the wrong place.</p><p>The check turns this into a startup message with instructions for action.</p><h3 id="failing-with-a-statement">Failing with a Statement</h3><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="nd">@SuppressFBWarnings</span><span class="p">(</span><span class="n">value</span><span class="w"/><span class="o">=</span><span class="w"/><span class="s">"DM_EXIT"</span><span class="p">,</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">justification</span><span class="w"/><span class="o">=</span><span class="w"/><span class="s">"intentional — main-class launcher exits non-zero so a process supervisor sees the failure"</span><span class="p">)</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kt">void</span><span class="w"/><span class="nf">abort</span><span class="p">(</span><span class="n">String</span><span class="w"/><span class="n">message</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">message</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="p">)</span><span class="w"/><span class="n">logger</span><span class="p">().</span><span class="na">error</span><span class="p">(</span><span class="n">message</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">System</span><span class="p">.</span><span class="na">exit</span><span class="p">(</span><span class="n">1</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p><code>System.exit(1)</code> in an application is normally a warning sign. In a launcher
class it is the right answer: systemd distinguishes a service that
terminates with an error code from one that simply does not respond. Without
this call, the JVM would keep running,<code>Restart=on-failure</code> would not kick
in, and the service would count as &ldquo;running&rdquo;.</p><h2 id="creating-and-configuring-jetty">Creating and Configuring Jetty</h2><p>Four lines bring the server up. Two of them deserve an explanation.</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">Server</span><span class="w"/><span class="n">server</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">Server</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">HttpConfiguration</span><span class="w"/><span class="n">httpConfig</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">HttpConfiguration</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">httpConfig</span><span class="p">.</span><span class="na">addCustomizer</span><span class="p">(</span><span class="k">new</span><span class="w"/><span class="n">ForwardedRequestCustomizer</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">httpConfig</span><span class="p">.</span><span class="na">setSendServerVersion</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">ServerConnector</span><span class="w"/><span class="n">connector</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">ServerConnector</span><span class="p">(</span><span class="n">server</span><span class="p">,</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">HttpConnectionFactory</span><span class="p">(</span><span class="n">httpConfig</span><span class="p">));</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">connector</span><span class="p">.</span><span class="na">setHost</span><span class="p">(</span><span class="n">host</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">connector</span><span class="p">.</span><span class="na">setPort</span><span class="p">(</span><span class="n">port</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">server</span><span class="p">.</span><span class="na">addConnector</span><span class="p">(</span><span class="n">connector</span><span class="p">);</span></span></span></code></pre></div></div><h3 id="server-without-a-thread-pool"><code>Server</code> Without a Thread Pool</h3><p><code>new Server()</code> creates a<code>QueuedThreadPool</code> with default values behind the
scenes. A custom pool would be possible:</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">QueuedThreadPool</span><span class="w"/><span class="n">pool</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">QueuedThreadPool</span><span class="p">(</span><span class="n">200</span><span class="p">,</span><span class="w"/><span class="n">8</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">Server</span><span class="w"/><span class="n">server</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">Server</span><span class="p">(</span><span class="n">pool</span><span class="p">);</span></span></span></code></pre></div></div><p>Here, deliberately, there is none. The reference setup drew the limit
elsewhere:<code>TasksMax=256</code> in the systemd unit from Part 1, Chapter 11. Two
upper limits for the same thing are one upper limit too many — the smaller
one wins, and whoever changes the other wonders why nothing happens.
Measured in operation, 30 threads are running; the defaults are sufficient
by a wide margin.</p><h3 id="forwardedrequestcustomizer--the-line-whose-absence-nobody-notices"><code>ForwardedRequestCustomizer</code> — the Line Whose Absence Nobody Notices</h3><p>This single line replaces<code>--add-modules=forwarded</code> from Part 2, Chapter 4.
What it does and why its absence is dangerous is covered in Chapter 10; here,
only the place where it belongs: on the<code>HttpConfiguration</code>,<strong>before</strong> the
connector is created. Added afterwards, it no longer takes effect, because
the<code>HttpConnectionFactory</code> has already captured the configuration.</p><h3 id="setsendserverversionfalse"><code>setSendServerVersion(false)</code></h3><p>Without this line, Jetty sends a<code>Server: Jetty(12.1.12)</code> header entry with
every response. That is not a security hole, but it is free information for
every scanner about which release to attack. The external Jetty switched
this off via<code>start.d/http-config.ini</code>; here it is one line of Java.</p><h3 id="bind-address-the-same-decision-a-different-place">Bind Address: the Same Decision, a Different Place</h3><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">connector</span><span class="p">.</span><span class="na">setHost</span><span class="p">(</span><span class="n">host</span><span class="p">);</span><span class="w"/><span class="o">//</span><span class="w"/><span class="n">127</span><span class="p">.</span><span class="na">0</span><span class="p">.</span><span class="na">0</span><span class="p">.</span><span class="na">1</span></span></span></code></pre></div></div><p>Part 1, Chapter 8 explained why the application server listens exclusively
on the loopback address: what cannot be reached does not have to be
defended. The reasoning holds unchanged — only now it lives in the source
code instead of a configuration file.</p><p>And it is the precondition for the line above it. Evaluating forwarding
headers is only defensible because the only party able to set them is the
reverse proxy on the same machine. If the connector were bound to<code>0.0.0.0</code>,
anyone on the network could claim the request arrived over HTTPS from any
address they like.</p><p><strong>These two lines belong together, and in the embedded setup they stand
visibly side by side for the first time.</strong> In the external Jetty they lived
in two different<code>.ini</code> files.</p><h2 id="configuring-the-servlet-context">Configuring the Servlet Context</h2><p>The server alone does not answer any request. It needs a context — the
counterpart to what the external Jetty created when unpacking a WAR.</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">WebAppContext</span><span class="w"/><span class="n">webapp</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">WebAppContext</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">webapp</span><span class="p">.</span><span class="na">setContextPath</span><span class="p">(</span><span class="s">"/"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">webapp</span><span class="p">.</span><span class="na">setBaseResource</span><span class="p">(</span><span class="n">webResources</span><span class="p">(</span><span class="n">webapp</span><span class="p">));</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">String</span><span class="w"/><span class="n">scanPattern</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">System</span><span class="p">.</span><span class="na">getProperty</span><span class="p">(</span><span class="s">"app.scan.pattern"</span><span class="p">,</span><span class="w"/><span class="n">DEFAULT_SCAN_PATTERN</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">webapp</span><span class="p">.</span><span class="na">setAttribute</span><span class="p">(</span><span class="n">MetaInfConfiguration</span><span class="p">.</span><span class="na">CONTAINER_JAR_PATTERN</span><span class="p">,</span><span class="w"/><span class="n">scanPattern</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">webapp</span><span class="p">.</span><span class="na">setAttribute</span><span class="p">(</span><span class="n">MetaInfConfiguration</span><span class="p">.</span><span class="na">WEBINF_JAR_PATTERN</span><span class="p">,</span><span class="w"/><span class="n">scanPattern</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">webapp</span><span class="p">.</span><span class="na">setParentLoaderPriority</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span></span></span></code></pre></div></div><h3 id="webappcontext-or-servletcontexthandler"><code>WebAppContext</code> or<code>ServletContextHandler</code>?</h3><p>Jetty offers both.<code>ServletContextHandler</code> is the leaner path: no scan, no
separate class loader, less startup time. For many embedded applications it
is the right choice.</p><p>For a Vaadin application it is not. Vaadin finds its<code>@Route</code> classes
through the<code>RouteRegistryInitializer</code> — a<code>ServletContainerInitializer</code>
announced via<code>META-INF/services</code> and<strong>searched for</strong> by the container.
Take that search away, and you have to register the routes by hand. The
effort does not disappear; it merely changes location.</p><p><code>WebAppContext</code> brings the search along. The price is in the next line.</p><h3 id="the-scan-pattern-is-a-tuning-knob">The Scan Pattern Is a Tuning Knob</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">DEFAULT_SCAN_PATTERN</span><span class="w"/><span class="o">=</span><span class="w"/><span class="s">".*\\.jar$"</span><span class="p">;</span></span></span></code></pre></div></div><p>This pattern says: open<strong>every</strong> archive on the classpath and search it for
initializers with ASM. In the reference setup, that is 127 of them.</p><p>That is why the pattern does not sit in the code as a constant but behind a
system property:</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 -Dapp.scan.pattern<span class="o">=</span><span class="s1">'.*(vaadinapp|flow|vaadin|jcustos)-.*\.jar$'</span> …</span></span></code></pre></div></div><p>Narrowing it down shortens startup measurably — and buys you a list that
wants to be maintained with every new dependency. The reference setup
therefore leaves it wide; the option is in the code anyway, because
otherwise nobody would find it.</p><p><strong>How much the scan accounts for can be quantified with involuntary
precision</strong>: a start without the scan takes 139 ms, with the scan 1,523 ms.
How this measurement came about is told in Chapter 12 — it was a failure.</p><h3 id="setparentloaderprioritytrue"><code>setParentLoaderPriority(true)</code></h3><p><code>WebAppContext</code> comes with its own class loader, which normally searches the
web application<strong>first</strong> and only then the parent. In the WAR model that is
correct: an application may bring its own release of a library without the
container interfering.</p><p>Embedded, this separation no longer exists. Application and server sit on
the same classpath; a class loader that continues to assume two worlds only
creates confusion — in the worst case the same class loaded twice, with a<code>ClassCastException</code> nobody understands.<code>true</code> switches back to the
ordinary Java order.</p><p>It is the same issue that Part 1, Section 4.5 described from the other side:
there, Jetty&rsquo;s precedence rules protected the installed Jetty from being
displaced by a bundled one. Here there is no installed Jetty left to
protect.</p><h2 id="integrating-vaadin">Integrating Vaadin</h2><p>The context is in place. Now the application goes in — and in the process, a
file resurfaces that was taken for granted in the WAR model.</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">ServletHolder</span><span class="w"/><span class="n">holder</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">ServletHolder</span><span class="p">(</span><span class="k">new</span><span class="w"/><span class="n">AppServlet</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">holder</span><span class="p">.</span><span class="na">setInitParameter</span><span class="p">(</span><span class="s">"i18n.provider"</span><span class="p">,</span><span class="w"/><span class="s">"com.svenruppert.flow.i18n.AppI18NProvider"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">holder</span><span class="p">.</span><span class="na">setAsyncSupported</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">holder</span><span class="p">.</span><span class="na">setInitOrder</span><span class="p">(</span><span class="n">1</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">webapp</span><span class="p">.</span><span class="na">addServlet</span><span class="p">(</span><span class="n">holder</span><span class="p">,</span><span class="w"/><span class="s">"/*"</span><span class="p">);</span></span></span></code></pre></div></div><p>Five lines that do exactly what the<code>web.xml</code> does in the WAR 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;servlet&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;servlet-name&gt;</span>Vaadin Servlet<span class="nt">&lt;/servlet-name&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;servlet-class&gt;</span>com.svenruppert.flow.AppServlet<span class="nt">&lt;/servlet-class&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;async-supported&gt;</span>true<span class="nt">&lt;/async-supported&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;load-on-startup&gt;</span>1<span class="nt">&lt;/load-on-startup&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;init-param&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;param-name&gt;</span>i18n.provider<span class="nt">&lt;/param-name&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;param-value&gt;</span>com.svenruppert.flow.i18n.AppI18NProvider<span class="nt">&lt;/param-value&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/init-param&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/servlet&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;servlet-mapping&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;servlet-name&gt;</span>Vaadin Servlet<span class="nt">&lt;/servlet-name&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;url-pattern&gt;</span>/*<span class="nt">&lt;/url-pattern&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/servlet-mapping&gt;</span></span></span></code></pre></div></div><p>Line for line the same. That is no coincidence — the deployment descriptor
and programmatic registration are two notations for the same Servlet API.</p><h3 id="the-parameter-that-costs-a-language">The Parameter That Costs a Language</h3><p><code>i18n.provider</code> looks like a nicety and is not. Vaadin V25 does<strong>not</strong> look
up the<code>I18NProvider</code> via<code>META-INF/services</code> but exclusively through this
init parameter. If it is missing, the framework uses<code>DefaultI18NProvider</code> —
and that one has a defect Part 1 already mentioned: it falls back to the
JVM&rsquo;s default language. On a German machine, every request for English then
returns German text.</p><p>The application starts without complaint. The language switcher appears, it
can be operated, and it changes nothing. Whoever discovers this in
production searches for a long time.</p><p><strong>It is the first of three values that get lost silently during the
migration</strong> — Chapter 10 covers the other two.</p><h3 id="setinitorder1"><code>setInitOrder(1)</code></h3><p>The counterpart to<code>&lt;load-on-startup&gt;1&lt;/load-on-startup&gt;</code>. Without it, the
container creates the servlet only on the first request. The application
still runs — but the first visitor pays for the entire initialization:
building the Vaadin services, opening the datastore, and wiring the security
layers.</p><p>For a service behind a reverse proxy this means: the first request after
every restart runs into a timeout, and the access log records a 502 with no
explanation. The number 1 moves this work into startup, where it belongs and
where<code>systemctl start</code> waits for it.</p><h3 id="appservlet-stays-where-it-is"><code>AppServlet</code> Stays Where It Is</h3><p>What is remarkable is what does<strong>not</strong> appear here: no modified servlet
class. It is the same<code>AppServlet</code> as in the WAR, unchanged, from the core
module.</p><p>Both delivery formats register the same class with the same parameters — one
through a descriptor, the other through five lines of Java. The difference
between Parts 2 and 3 is not in the application. It is in who starts it.</p><h2 id="production-resources">Production Resources</h2><p>A WAR has a root directory. Everything inside it is served by the container.
An application archive has none — and that is exactly where the migration
fails most often.</p><h3 id="two-kinds-of-files-one-problem">Two Kinds of Files, One Problem</h3><p>The application serves two kinds of static files:</p><table><thead><tr><th/><th>where they come from</th></tr></thead><tbody><tr><td>the built Vaadin frontend</td><td><code>build-frontend</code>, under<code>META-INF/VAADIN/webapp/</code></td></tr><tr><td>images and icons</td><td>maintained by hand, in the WAR under<code>src/main/webapp/</code></td></tr></tbody></table><p>The first kind was never a problem: Vaadin puts it in a place it finds again
on its own. The second kind lived in the WAR root — and that root no longer
exists.</p><h3 id="the-solution-is-in-the-servlet-specification">The Solution Is in the Servlet Specification</h3><p>An archive may contribute web resources if they sit under<code>META-INF/resources</code>. The container serves them as if they were in the web
application&rsquo;s root directory. That is precisely what this mechanism is for,
and it applies to both delivery formats.</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>core/src/main/resources/META-INF/resources/
├── images/portrait-sven.jpg
└── icons/github-mark.svg, linkedin-mark.png</code></pre></div><p>This keeps the references in the source code unchanged:</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="k">new</span><span class="w"/><span class="n">Image</span><span class="p">(</span><span class="s">"images/portrait-sven.jpg"</span><span class="p">,</span><span class="w"/><span class="err">…</span><span class="p">)</span></span></span></code></pre></div></div><p>One source, two packagings, the same URLs. The WAR gets the files through<code>WEB-INF/lib/vaadinapp-core.jar</code>, the embedded setup through the classpath.</p><h3 id="the-four-lines-without-which-it-is-not-enough">The Four Lines Without Which It Is Not Enough</h3><p>For the WAR, the container takes care of the rest. Embedded, the context
must learn where its base is — and<strong>one</strong> lookup is not enough:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="n">Resource</span><span class="w"/><span class="nf">webResources</span><span class="p">(</span><span class="n">WebAppContext</span><span class="w"/><span class="n">webapp</span><span class="p">)</span><span class="w"/><span class="kd">throws</span><span class="w"/><span class="n">IOException</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">ResourceFactory</span><span class="w"/><span class="n">factory</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">ResourceFactory</span><span class="p">.</span><span class="na">of</span><span class="p">(</span><span class="n">webapp</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">List</span><span class="o">&lt;</span><span class="n">Resource</span><span class="o">&gt;</span><span class="w"/><span class="n">roots</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">Collections</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><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="na">getResources</span><span class="p">(</span><span class="s">"META-INF/resources/"</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">stream</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">map</span><span class="p">(</span><span class="n">url</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">factory</span><span class="p">.</span><span class="na">newResource</span><span class="p">(</span><span class="n">url</span><span class="p">.</span><span class="na">toString</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">filter</span><span class="p">(</span><span class="n">Objects</span><span class="p">::</span><span class="n">nonNull</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">toList</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">logger</span><span class="p">().</span><span class="na">info</span><span class="p">(</span><span class="s">"Serving static web resources from {} classpath root(s)"</span><span class="p">,</span><span class="w"/><span class="n">roots</span><span class="p">.</span><span class="na">size</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">return</span><span class="w"/><span class="n">ResourceFactory</span><span class="p">.</span><span class="na">combine</span><span class="p">(</span><span class="n">roots</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p><code>getResources</code>, plural, not<code>getResource</code>. In the reference setup, this line
reports at startup:</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>Serving static web resources from 5 classpath root(s)</code></pre></div><p>Five, not one: the core module contributes images and icons, the Vaadin
archives their themes and the client engine. Take only the first root, and
you get an application in which either your own images or the Vaadin theme
is missing — depending on which archive the class loader finds first.</p><h3 id="the-failure-case-demonstrated-once">The Failure Case, Demonstrated Once</h3><p>The most expensive mistake in this part does not look like one at all. A
build without the production profile:</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 clean package<span class="c1"># without -Pproduction</span></span></span></code></pre></div></div><p>Afterwards, the built frontend is missing. The server starts anyway:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>[main] INFO org.eclipse.jetty.server.Server - Started oejs.Server@… @1123ms</code></pre></div><p><code>curl</code> reports 200. The access log is unremarkable. In the browser the page
stays<strong>white</strong>, and the only hint sits in the developer console: references
to<code>VAADIN/build/…</code> that return 404.</p><p>That is why<code>Application</code> contains the check from Chapter 4:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">BUNDLE_MARKER</span><span class="w"/><span class="o">=</span><span class="w"/><span class="s">"META-INF/VAADIN/webapp/index.html"</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="k">if</span><span class="w"/><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="na">getResource</span><span class="p">(</span><span class="n">BUNDLE_MARKER</span><span class="p">)</span><span class="w"/><span class="o">==</span><span class="w"/><span class="kc">null</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">abort</span><span class="p">(</span><span class="s">"Vaadin production bundle missing from the classpath (expected "</span><span class="w"/><span class="o">+</span><span class="w"/><span class="n">BUNDLE_MARKER</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="o">+</span><span class="w"/><span class="s">"). Build with `mvn -Pproduction package` before starting; without it the "</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="o">+</span><span class="w"/><span class="s">"server starts, answers 200 and serves a blank page."</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>A mute browser error becomes a startup message with instructions for action,
and the service terminates with an error code instead of appearing to run.</p><p><strong>Five lines against a lost evening.</strong> Part 1, Chapter 4 described the same
trap for the WAR; there it remained a warning. Here it is caught, because
embedded there is nobody left who would complain.</p><h3 id="where-the-bundle-is-built">Where the Bundle Is Built</h3><p>One point that surprises when splitting into modules:<code>build-frontend</code> runs<strong>not</strong> in the packaging module but in the core.</p><p>The Vaadin documentation is unambiguous here. The build generates the
frontend files in the module that configures the plugin; with<code>jar</code>
packaging they end up under<code>target/classes/META-INF/VAADIN/webapp/</code> and
thus travel into the core archive. Both packagings fetch them from there via
the classpath.</p><p>The cross-check after the build:</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">unzip -l core/target/vaadinapp-core-*.jar<span class="p">|</span> grep -c<span class="s1">'META-INF/VAADIN/webapp'</span></span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>137</code></pre></div><p>Put the plugin into a packaging module instead, and you build the bundle
where the other packaging cannot see it — and find the mistake only on the
first page load.</p><h2 id="lifecycle">Lifecycle</h2><p>The server is assembled. What remains is the question of who stops it — and
what happens when nobody thinks of it.</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">Server</span><span class="w"/><span class="n">server</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">server</span><span class="p">(</span><span class="n">host</span><span class="p">,</span><span class="w"/><span class="n">port</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">server</span><span class="p">.</span><span class="na">start</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">().</span><span class="na">info</span><span class="p">(</span><span class="s">"Embedded Jetty serving http://{}:{}/"</span><span class="p">,</span><span class="w"/><span class="n">host</span><span class="p">,</span><span class="w"/><span class="n">port</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">server</span><span class="p">.</span><span class="na">join</span><span class="p">();</span></span></span></code></pre></div></div><h3 id="start-returns-join-does-not"><code>start()</code> Returns,<code>join()</code> Does Not</h3><p><code>start()</code> brings the server up and returns control. Without the last line,<code>main()</code> would then run to its end, the JVM would exit, and the service
would be gone again after one second.</p><p><code>join()</code> blocks until the server stops. This one line is the difference
between a program that gets something done and a service that runs.</p><p>The message in between is not decoration. It is the only place where the
journal records which address the service actually listens on — and thus the
first thing you look for when Caddy reports 502.</p><h3 id="stopping-without-damaging-the-datastore">Stopping Without Damaging the Datastore</h3><p>The application keeps an Eclipse Store datastore open. A process that simply
gets killed leaves it unchanged in the best case and incomplete in the
worst.</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">server</span><span class="p">.</span><span class="na">setStopAtShutdown</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span></span></span></code></pre></div></div><p>With this, Jetty registers its own shutdown hook with the JVM: on<code>SIGTERM</code>,
the server shuts down in an orderly fashion, the contexts are stopped, and
the application gets the opportunity to close its resources.</p><p>The counterpart in the systemd unit has been in place since Part 1:</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>KillSignal=SIGTERM
TimeoutStopSec=30</code></pre></div><p>systemd sends<code>SIGTERM</code> and waits thirty seconds before getting tougher.
Both sides have to match — a service with a shutdown hook and<code>KillSignal=SIGKILL</code> would have the hook for nothing.</p><h3 id="the-line-that-was-not-needed-in-the-war-model">The Line That Was Not Needed in the WAR Model</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>SuccessExitStatus=143</code></pre></div><p>143 is<code>128 + 15</code>, that is, &ldquo;terminated by signal 15&rdquo;. A service that shuts
down properly on<code>SIGTERM</code> exits with exactly this value — and without this
line, systemd would consider that a failure. With<code>Restart=on-failure</code>, that
would lead to a service that restarts itself after every planned stop.</p><p>The line dates from Part 1 and continues to apply unchanged. It appears here
because it is easy to overlook in the embedded setup: whoever rewrites the
unit instead of adapting it leaves it out and wonders.</p><h3 id="failure-at-startup">Failure at Startup</h3><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="p">}</span><span class="w"/><span class="k">catch</span><span class="w"/><span class="p">(</span><span class="n">Exception</span><span class="w"/><span class="n">e</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">logger</span><span class="p">().</span><span class="na">error</span><span class="p">(</span><span class="s">"Failed to start embedded Jetty on {}:{}"</span><span class="p">,</span><span class="w"/><span class="n">host</span><span class="p">,</span><span class="w"/><span class="n">port</span><span class="p">,</span><span class="w"/><span class="n">e</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">stop</span><span class="p">(</span><span class="n">server</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">abort</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p><code>stop(server)</code> before<code>abort(...)</code>: if the server has already claimed a port
and something went wrong only afterwards, this call releases it. Without it,
the port would stay occupied until the process ends — and on the automatic
restart five seconds later, the next attempt would fail with a<code>BindException</code> whose cause then looks like an entirely different problem.</p><h2 id="what-the-module-mechanism-used-to-take-care-of">What the Module Mechanism Used to Take Care Of</h2><p>The server runs, the application responds, the pages render. This is where a
migration usually stops — and this is exactly where the most important part
is still missing.</p><p>Part 2 spent two chapters setting up the external Jetty for operation behind
Caddy. Both happened through modules:</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 java -jar /opt/jetty/start.jar --add-modules<span class="o">=</span>forwarded</span></span><span class="line"><span class="cl">sudo java -jar /opt/jetty/start.jar --add-modules<span class="o">=</span>ee11-websocket-jakarta</span></span></code></pre></div></div><p>Embedded, there are no modules. Both have to go into the code — and<strong>both
fail silently if you forget them.</strong> No error, no log entry, no complaint.</p><h3 id="first-the-request-does-not-come-from-where-it-comes-from">First: The Request Does Not Come from Where It Comes From</h3><p>Behind Caddy, every request reaches the application server via<code>127.0.0.1</code>,
unencrypted, with a different hostname than the one the browser called. From
the application&rsquo;s point of view, this looks like local access over HTTP.</p><p>The consequence is not an error message but a wrong conclusion: Vaadin sets
the session cookie<strong>without</strong> the<code>Secure</code> flag, because the connection
appears to be unencrypted. A cookie without this flag may also be sent by
the browser over unencrypted connections.</p><p>One line fixes this:</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">httpConfig</span><span class="p">.</span><span class="na">addCustomizer</span><span class="p">(</span><span class="k">new</span><span class="w"/><span class="n">ForwardedRequestCustomizer</span><span class="p">());</span></span></span></code></pre></div></div><p>The proof takes two calls — without and with the header Caddy sets:</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">curl -s -D- -o /dev/null http://127.0.0.1:8080/login<span class="p">|</span> grep -i set-cookie</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>set-cookie: JSESSIONID=node0…; Path=/</code></pre></div><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">curl -s -D- -o /dev/null -H<span class="s1">'X-Forwarded-Proto: https'</span><span class="se">\</span></span></span><span class="line"><span class="cl"> http://127.0.0.1:8080/login<span class="p">|</span> grep -i set-cookie</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>set-cookie: JSESSIONID=node0…; Path=/; Secure</code></pre></div><p>Same server, same application, one difference in the result. Checked via the
public address:</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">curl -s -D- -o /dev/null https://demo.svenruppert.com/login<span class="p">|</span> grep -i set-cookie</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>set-cookie: JSESSIONID=node01ggmnynrf3f7q162xs5dytkyna5.node0; Path=/; Secure</code></pre></div><h3 id="why-evaluating-them-is-defensible-at-all">Why Evaluating Them Is Defensible at All</h3><p>Forwarding headers are claims made by the sender. Whoever evaluates them
believes a stranger about where the request comes from and over which
protocol.</p><p>What makes this defensible is a decision from Part 1, Chapter 8: the
connector listens on<code>127.0.0.1</code>. The only party able to set these headers
is thus a process on the same machine — the reverse proxy. If it were bound
to<code>0.0.0.0</code>, anyone on the network could claim their request arrived over
HTTPS from any address, and the application would write it into the log and
into its access decisions.</p><p>In the external Jetty, these two decisions that belong together lived in two
different<code>.ini</code> files. In the code, they are seven lines apart.</p><h3 id="second-push-needs-a-container-that-nobody-brings-along">Second: Push Needs a Container That Nobody Brings Along</h3><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">JakartaWebSocketServletContainerInitializer</span><span class="p">.</span><span class="na">configure</span><span class="p">(</span><span class="n">webapp</span><span class="p">,</span><span class="w"/><span class="kc">null</span><span class="p">);</span></span></span></code></pre></div></div><p>Vaadin Push keeps a persistent connection open. For that, the servlet
context needs WebSocket support, which is not present on its own in the
embedded setup. The call must happen<strong>before</strong> the context starts;
afterwards it is too late, and no message tells you so.</p><p>Without it, Vaadin falls back to long polling, or the connection fails. The
application remains usable — server-initiated updates just no longer arrive,
or arrive late.</p><p>The proof is a connection upgrade:</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">curl -s -o /dev/null -w<span class="s1">'%{http_code}\n'</span><span class="se">\</span></span></span><span class="line"><span class="cl"> -H<span class="s1">'Connection: Upgrade'</span> -H<span class="s1">'Upgrade: websocket'</span><span class="se">\</span></span></span><span class="line"><span class="cl"> -H<span class="s1">'Sec-WebSocket-Version: 13'</span> -H<span class="s1">'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=='</span><span class="se">\</span></span></span><span class="line"><span class="cl"><span class="s1">'http://127.0.0.1:8080/VAADIN/push?v-r=push&amp;X-Atmosphere-transport=websocket'</span></span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>101</code></pre></div><p><strong>101 Switching Protocols</strong> — the upgrade succeeds. A 200 here would be the
warning sign: then ordinary request processing would have answered, and the
upgrade would not have happened.</p><h3 id="third-the-language-provider-from-chapter-7">Third: The Language Provider from Chapter 7</h3><p>The<code>i18n.provider</code> parameter belongs to the same family. It, too, used to
live in a file the container read — the<code>web.xml</code> —, its absence, too,
produces no error, and its consequence, too, only shows in operation.</p><h3 id="the-pattern-behind-it">The Pattern Behind It</h3><p>Three values, three failures without a symptom. That is no coincidence but
the nature of configuration that exists as a<strong>default</strong>: if it is missing,
a default value applies, and default values are made for the simple case —
directly reachable server, no encryption, no persistent connections, one
language.</p><p>The embedded setup is therefore no less secure than the external one. It
merely shifts where the decision lives: from a file someone once created
into code that gets compiled. What is missing in both cases is feedback —
which is why these three points are paired with the three checks from this
chapter, and why those checks belong in the operating procedures and not
only here.</p><h2 id="configuring-the-embedded-server">Configuring the Embedded Server</h2><p>Part 2, Chapter 6 established the rule: configuration does not belong in the
artifact. It applies here unchanged — only the artifact is a different one.</p><h3 id="three-sources-fixed-order">Three Sources, Fixed Order</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="n">String</span><span class="w"/><span class="nf">resolve</span><span class="p">(</span><span class="n">String</span><span class="w"/><span class="n">systemProperty</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">envVariable</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fallback</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fromSystem</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">System</span><span class="p">.</span><span class="na">getProperty</span><span class="p">(</span><span class="n">systemProperty</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">fromSystem</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="o">!</span><span class="n">fromSystem</span><span class="p">.</span><span class="na">isBlank</span><span class="p">())</span><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fromSystem</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fromEnv</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">System</span><span class="p">.</span><span class="na">getenv</span><span class="p">(</span><span class="n">envVariable</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">fromEnv</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="o">!</span><span class="n">fromEnv</span><span class="p">.</span><span class="na">isBlank</span><span class="p">())</span><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fromEnv</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fallback</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><table><thead><tr><th>Source</th><th>how it is set</th><th>used for</th></tr></thead><tbody><tr><td>system property</td><td><code>-Dapp.port=8081</code> in the unit</td><td>one-off deviation, clearly visible</td></tr><tr><td>environment variable</td><td><code>EnvironmentFile=/etc/vaadinapp/environment</code></td><td>the standard case on the server</td></tr><tr><td>default in the code</td><td>—</td><td>the development machine</td></tr></tbody></table><p>The order is not arbitrary. The system property wins because it lives in the
unit and thus under the server&rsquo;s version control; the environment variable
comes from a file that someone else can maintain as well; the default only
applies where nobody said anything.</p><h3 id="what-is-deliberately-absent-here">What Is Deliberately Absent Here</h3><p><strong>No configuration file format.</strong> It would be possible to read an<code>application.properties</code>. The reference setup does not, because every
additional source makes the question &ldquo;where does this value come from&rdquo;
harder to answer — and because systemd, with<code>EnvironmentFile</code>, already
provides an answer that needs no library.</p><p><strong>No reloading at runtime.</strong> The service reads its configuration at startup.
Whoever changes it restarts — four seconds of downtime, measured in
Chapter 12. An application that picks up configuration while running has to
answer, for every value, what happens to the requests that are in flight at
that moment. That is effort that does not pay off here.</p><h3 id="the-difference-from-the-war-model">The Difference from the WAR Model</h3><p>Exactly<strong>one</strong>:<code>app.storage.dir</code>. This property has been in the unit since
Part 1, because the application&rsquo;s default is a relative path and<code>ProtectSystem=strict</code> makes the working directory read-only. Nothing
changes here — the finding from Part 1 applies to both delivery formats
alike.</p><p>Everything else — credentials via<code>systemd-creds</code>,<code>EnvironmentFile</code>, file
permissions — is carried over unchanged from Part 2. That is the actual
message of this chapter: the operational framework does not care who starts
the server.</p><h2 id="embedded-jetty-under-systemd">Embedded Jetty Under systemd</h2><p>The unit from Part 1 changes<strong>one</strong> directive. The rest stays, line for
line — and that is the outcome the separation from Part 0 was aiming at.</p><h3 id="before-and-after">Before and After</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">diff</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-diff" data-lang="diff"><span class="line"><span class="cl"> ExecStart=/var/lib/vaadinapp/.sdkman/candidates/java/current/bin/java \</span></span><span class="line"><span class="cl"><span class="gd">- -Djetty.home=/opt/jetty \</span></span></span><span class="line"><span class="cl"><span class="gd">- -Djetty.base=/opt/vaadinapp \</span></span></span><span class="line"><span class="cl"> -Djava.io.tmpdir=/var/lib/vaadinapp/work \</span></span><span class="line"><span class="cl"> -Dapp.storage.dir=/var/lib/vaadinapp/data \</span></span><span class="line"><span class="cl"> --add-exports java.base/jdk.internal.misc=ALL-UNNAMED \</span></span><span class="line"><span class="cl"> --enable-native-access=ALL-UNNAMED \</span></span><span class="line"><span class="cl"> -XX:MaxRAMPercentage=75 \</span></span><span class="line"><span class="cl"> -XX:+ExitOnOutOfMemoryError \</span></span><span class="line"><span class="cl"><span class="gd">- -jar /opt/jetty/start.jar</span></span></span><span class="line"><span class="cl"><span class="gi">+ -cp /opt/vaadinapp/app.jar:/opt/vaadinapp/lib/* \</span></span></span><span class="line"><span class="cl"><span class="gi">+ com.svenruppert.flow.Application</span></span></span></code></pre></div></div><p>Two lines go, two come in.<strong>Both hardening drop-ins remain untouched</strong> —
the same directives, the same hardening score of 1.1.</p><p>The asterisk in<code>lib/*</code> is not a shell glob. systemd does not expand it, and
that is a good thing: Java knows this notation in the classpath itself and
resolves it at startup.</p><h3 id="the-directory-on-the-server">The Directory on the Server</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/
├── app.jar 8,908 bytes — just the launcher class
├── lib/ 127 archives, 29.74 MB
├── logs/
└── releases/ archive of the shipped releases</code></pre></div><p>The application archive is nine kilobytes. It contains one class. Everything
else sits next to it — including the application&rsquo;s core, as one of the 127
archives.</p><h3 id="-the-first-start-fails">⚠️ The First Start Fails</h3><p>It actually did fail, with a 503:</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.lang.RuntimeException: Unable to create FileSystem for /opt/vaadinapp/lib/._jspecify-1.0.0.jar
Caused by: java.util.zip.ZipException: zip END header not found</code></pre></div><p><code>._jspecify-1.0.0.jar</code> — with a leading dot and underscore. That is not a
library but a companion file macOS creates for files with extended
attributes. When packing with<code>tar</code>, they travel into the archive; the hint
is already there at unpack time:</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>tar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'</code></pre></div><p>Eleven such files ended up in<code>lib/</code>. And Java&rsquo;s classpath wildcard<code>lib/*</code>
picks up<strong>every</strong> file ending in<code>.jar</code> — it checks nothing, it only
expands.</p><p>The remedy when packing:</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.jar lib</span></span></code></pre></div></div><p>or on the target system:</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">find /opt/vaadinapp -name<span class="s1">'._*'</span> -delete</span></span></code></pre></div></div><p><strong>Why this could not happen with the WAR:</strong> a WAR is a single archive
written by Maven. Only a classpath built from a directory makes the
application depend on that directory containing nothing but valid archives.
The delivery format brings a new source of error that has nothing to do with
Jetty.</p><h3 id="-why-there-is-no-java--jar">⚠️ Why There Is No<code>java -jar</code></h3><p>The obvious idea: use<code>addClasspath</code> to write a<code>Class-Path</code> into the
application archive&rsquo;s manifest, and<code>java -jar app.jar</code> will do.</p><p>That works —<strong>apparently</strong>. The class loader resolves the archives, the
server starts, the application responds. Every request, however, then ends
with:</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.lang.NullPointerException: Cannot invoke
"com.vaadin.flow.server.StaticFileHandler.serveStaticResource(…)"</code></pre></div><p>The reason sits one level deeper. With<code>java -jar</code>, the<code>java.class.path</code>
property contains<strong>a single entry</strong> — the application archive. The
manifest&rsquo;s<code>Class-Path</code> entries are not in it. And this is precisely the
property Jetty&rsquo;s<code>MetaInfConfiguration</code> reads to decide which archives the
annotation scanner opens.</p><p>No scan, no<code>RouteRegistryInitializer</code>, no<code>StaticFileHandler</code>. The server
is healthy; the servlet is not.</p><p><strong>The telltale measurement is the startup time.</strong> With the identical 127
archives:</p><table><thead><tr><th>Start</th><th>Jetty&rsquo;s own timestamp</th></tr></thead><tbody><tr><td><code>java -jar app.jar</code></td><td><strong>139 ms</strong></td></tr><tr><td><code>java -cp 'app.jar:lib/*'</code></td><td><strong>1,523 ms</strong></td></tr></tbody></table><p>The missing second is the scan. Whoever does not know the difference takes
the faster start for a win.</p><p>That is why the manifest deliberately carries<strong>no</strong><code>Class-Path</code>, and the
launch command is explicitly:</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.jar:lib/*'</span> com.svenruppert.flow.Application</span></span></code></pre></div></div><h3 id="the-teardown">The Teardown</h3><p>After the switch, the Jetty installation becomes superfluous:</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 rm -f /opt/jetty<span class="c1"># Symlink</span></span></span><span class="line"><span class="cl">sudo rm -rf /opt/jetty-home-12.1.12<span class="c1"># the installation</span></span></span><span class="line"><span class="cl">sudo rm -rf /opt/vaadinapp/<span class="o">{</span>start.d,webapps,resources,environments<span class="o">}</span></span></span></code></pre></div></div><table><thead><tr><th>Directory</th><th>Size</th></tr></thead><tbody><tr><td><code>/opt/jetty-home-12.1.12</code></td><td>53 MB</td></tr><tr><td><code>/opt/vaadinapp/webapps</code></td><td>21 MB</td></tr><tr><td><code>start.d</code>,<code>resources</code>,<code>environments</code></td><td>80 KB</td></tr><tr><td><strong>total</strong></td><td><strong>71.1 MB</strong></td></tr></tbody></table><p>What is remarkable about the 53 MB: the distribution contains<strong>232
archives</strong>, of which<strong>26</strong> were on the classpath in operation. That is not
a defect but the way a server distribution is built — it ships all modules,
some get activated. Embedded, this reserve disappears, because the
dependencies are named.</p><p>Afterwards,<code>/opt</code> contains nothing but<code>vaadinapp</code>.</p><h2 id="caddy-remains-unchanged">Caddy Remains Unchanged</h2><p>The shortest chapter of this part, and one of the most telling.</p><h3 id="the-file">The File</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>demo.svenruppert.com {
reverse_proxy 127.0.0.1:8080
…
}</code></pre></div><p>Unchanged. No restart, no reload, no adjustment. Caddy talks to<code>127.0.0.1:8080</code> and does not know what runs behind it — previously an
installed servlet container with an archive inside, now an application with
its own server.</p><h3 id="why-this-cannot-be-taken-for-granted">Why This Cannot Be Taken for Granted</h3><p>Because it could easily have gone differently. The embedded setup could have</p><ul><li>listened on a different port,</li><li>bound to<code>0.0.0.0</code>,</li><li>ignored the forwarding headers,</li><li>refused WebSocket connections.</li></ul><p>In all four cases Caddy would have needed adjustment, or the application
would have worked incorrectly. That none of this was necessary is the result
of the decisions from Chapters 5, 7, and 10 — they are all aligned to offer
the same interface as before.</p><h3 id="the-cross-check">The Cross-Check</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">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">curl -s -D- -o /dev/null https://demo.svenruppert.com/login<span class="p">|</span> grep -i set-cookie</span></span><span class="line"><span class="cl">curl -s -D- -o /dev/null https://demo.svenruppert.com/<span class="p">|</span> grep -i strict-transport</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>200
set-cookie: JSESSIONID=node01ggmnynrf3f7q162xs5dytkyna5.node0; Path=/; Secure
strict-transport-security: max-age=31536000; includeSubDomains</code></pre></div><p>The same three lines as at the end of Part 2.</p><h3 id="the-boundary-that-becomes-visible-here">The Boundary That Becomes Visible Here</h3><p>Caddy and the application have a contract: HTTP on<code>127.0.0.1:8080</code>, with
forwarding headers. As long as both sides keep it, each side is replaceable
for the other.</p><p>That is why the series chose this order. Had the reverse proxy not already
been set up and described in Part 2, this part would have to cover it as
well — and the claim &ldquo;none of this changes&rdquo; would not be verifiable, because
there would be no before.</p><h2 id="security-implications">Security Implications</h2><p>The switch shifts a responsibility, and an uncomfortable one at that.</p><h3 id="the-jetty-version-now-belongs-to-the-application">The Jetty Version Now Belongs to the Application</h3><p>Before: when a security advisory for Jetty appears, someone downloads the
new distribution, moves the symlink, restarts the service. The application
is not touched, not rebuilt, not retested.</p><p>After: the same advisory means changing a number in the<code>pom.xml</code>,
rebuilding, running the test suite, and shipping a release.</p><table><thead><tr><th/><th>external Jetty</th><th>embedded</th></tr></thead><tbody><tr><td>Who decides on the version</td><td>the server maintainer</td><td>the application project</td></tr><tr><td>Effort for a security update</td><td>symlink and restart</td><td>build and release</td></tr><tr><td>Who notices that one is needed</td><td>whoever maintains the server</td><td>whoever checks the dependencies</td></tr></tbody></table><p><strong>The last row is the dangerous one.</strong> With the external Jetty, the
installation sits visibly under<code>/opt/jetty</code>; it shows up in every
inventory. Embedded, Jetty is one line among 127 in a dependency tree. A
server whose operating system is well maintained can contain a
three-year-old Jetty release without anyone noticing.</p><p>What helps against this is not a decision about the setup but one about the
process: a dependency check in the build that reports known
vulnerabilities. The project generates a bill of materials
(<code>CycloneDX-SBom</code>) with every build — so the foundation for that is in
place.</p><h3 id="what-gets-better">What Gets Better</h3><p><strong>There is no second release anymore.</strong> Part 1, Section 4.5 described the
case of a WAR bringing its own Jetty, with the installed release shadowing
it. As long as the two match, all goes well; after that, the wrong class
wins at some unshielded spot. This source of error disappears: there is only
one Jetty left, and it is recorded in the<code>pom.xml</code>.</p><p><strong>What was built is what runs.</strong> A WAR is an incomplete artifact — it needs
a container of matching release with matching modules. A classpath is
complete. What starts on the development machine also starts on the server,
because both use the same list.</p><p><strong>The server&rsquo;s attack surface shrinks.</strong> 71 MB less installed software, 232
fewer archives on disk, no deployment directory that a process with write
permissions could fill. The teardown from Chapter 12 is not just tidying up.</p><h3 id="what-stays-the-same-1">What Stays the Same</h3><p>The hardening score.<code>systemd-analyze security vaadinapp</code> reports<strong>1.1</strong>
before and after, with the same directives in the same drop-in.</p><p>That is expected and still worth mentioning: hardening describes what the
process<strong>may do</strong> — which directories it may write, which system calls it
may issue, which privileges it may escalate. These questions are independent
of who starts the server inside the process. A hardening score that changed
with the launch method would have been attached at the wrong place.</p><h2 id="external-versus-embedded-jetty">External Versus Embedded Jetty</h2><p>Both setups ran on the same machine, with the same application, behind the
same Caddy. What follows is measured, not estimated.</p><h3 id="the-numbers">The Numbers</h3><table><thead><tr><th>Metric</th><th>WAR on external Jetty</th><th>embedded</th></tr></thead><tbody><tr><td>Downtime until first response</td><td>5,461 ms</td><td><strong>4,685 ms</strong></td></tr><tr><td>Jetty uptime until<code>Started Server</code></td><td>3,969 ms</td><td><strong>3,634 ms</strong></td></tr><tr><td>Memory at idle (<code>MemoryCurrent</code>)</td><td>448 MiB</td><td><strong>336 MiB</strong></td></tr><tr><td>RSS</td><td>443 MiB</td><td><strong>361 MiB</strong></td></tr><tr><td>Threads</td><td>34</td><td><strong>30</strong></td></tr><tr><td>Archives on the classpath</td><td>112</td><td>127</td></tr><tr><td>Delivery size</td><td>21.78 MB</td><td>29.75 MB</td></tr><tr><td>additionally required on the server</td><td>53 MB installation</td><td>—</td></tr><tr><td>Hardening score</td><td>1.1</td><td>1.1</td></tr></tbody></table><p>Each value is the mean of three runs. Individual downtime values for the
embedded setup: 4,517, 4,868, and 4,670 ms.</p><h3 id="the-row-that-contradicts-expectation">The Row That Contradicts Expectation</h3><p>Part 2, Chapter 8 ended with an observation: an archive smaller by a quarter
had<strong>not</strong> reduced memory consumption. The explanation back then: what was
removed had never been loaded in the first place.</p><p>Here it is the other way around, and the explanation is the same. The
delivery<strong>grows</strong> from 21.78 to 29.75 MB, the classpath lengthens from 112
to 127 archives — and memory consumption still drops by 112 MiB.</p><p>Both times, what was measured was not the amount of bytes shipped but the
amount of<strong>loaded classes</strong>. The external Jetty brings its module system,
its deployment scanner, and its own startup machinery; the embedded setup
loads only what the application calls.</p><p><strong>Whoever takes an artifact&rsquo;s size as a measure of its resource consumption
is wrong in both directions.</strong></p><h3 id="what-the-numbers-do-not-show">What the Numbers Do Not Show</h3><p>Downtime drops by roughly 780 ms, which is less than the elimination of
unpacking would suggest. The reason is in the measurement from Part 2: over
forty percent of the startup time goes to Vaadin initialization — to the
application itself. Changing the launch mechanism cannot change that.</p><p>Whoever expects a dramatically faster start from embedded operation will be
disappointed. Four seconds remain four seconds.</p><h3 id="responsibility-maintenance-tooling">Responsibility, Maintenance, Tooling</h3><table><thead><tr><th/><th>external Jetty</th><th>embedded</th></tr></thead><tbody><tr><td>Install the server</td><td>yes, once and at every upgrade</td><td>no</td></tr><tr><td>Configure the server</td><td><code>start.d/*.ini</code>, 16 files</td><td>Java code, one class</td></tr><tr><td>Update the server</td><td>move the symlink, restart</td><td>build and release</td></tr><tr><td>Server version visible in</td><td>a directory on disk</td><td>the dependency tree</td></tr><tr><td>One server for several applications</td><td>possible</td><td>no, one per application</td></tr><tr><td>Development run</td><td>start the container</td><td>start<code>main()</code></td></tr></tbody></table><p>The second-to-last row is often overlooked. An installed Jetty can serve
several archives at once. Whoever runs three small applications on one
server pays the base footprint of a JVM three times when embedded. With one
application per server — the case of this series — that is not an argument.</p><h3 id="what-stays-the-same-in-both-cases">What Stays the Same in Both Cases</h3><p>Directory layout, service account, hardening, journald, configuration
outside the artifact, credentials via<code>systemd-creds</code>, Caddy, update and
rollback.<strong>Everything Parts 0 and 2 built survives the switch unchanged.</strong></p><p>That is not a side note but the argument for the order of this series:
whoever builds the operational framework first and keeps it independent of
the delivery format can swap that format later without touching everything
else again.</p><h2 id="when-does-your-own-embedded-jetty-make-sense">When Does Your Own Embedded Jetty Make Sense?</h2><p>The answer hangs on a single question:<strong>who maintains the server?</strong></p><h3 id="in-favor">In Favor</h3><p><strong>When the same person is responsible for the application and the server.</strong>
Then the separation between the two is artificial, and a number in the<code>pom.xml</code> is the shorter path than a symlink on a machine.</p><p><strong>When the application ships frequently.</strong> A security update for Jetty is
then no special case; it travels with the next release anyway.</p><p><strong>When the target server is supposed to contain as little as possible.</strong> A
JVM suffices. No server installation, no deployment directory, no module
configuration — 71 MB less and one software component fewer that someone has
to keep an eye on.</p><p><strong>When the setup is supposed to be reproducible.</strong> A classpath is complete.
A WAR is not: it presupposes a container of matching release with matching
modules, and this precondition is recorded nowhere in the artifact.</p><h3 id="against">Against</h3><p><strong>When several applications run on one server.</strong> An installed Jetty serves
several archives from one JVM. Embedded, every application pays its own base
footprint — with three small applications, that is the decisive point.</p><p><strong>When the server is maintained by someone else.</strong> Then the separation is
not an inconvenience but a boundary of responsibility. A maintainer who can
update Jetty without having every application rebuilt is an advantage, not
an obstacle.</p><p><strong>When nobody checks the dependencies.</strong> An embedded server disappears into
the dependency tree. Without a check in the build that reports known
vulnerabilities, the switch is a step backwards — not technically, but
organizationally.</p><p><strong>When the application needs container features</strong> that otherwise come for
free: JNDI resources, several contexts, a deployment directory for
third-party archives. All of it can be rebuilt in an embedded setup, and
none of it is worth the effort when it is available ready-made.</p><h3 id="the-honest-summary">The Honest Summary</h3><p>For the case of this series — one application, one server, one responsible
person, regular releases — the embedded setup is the better fit. It is not
faster in operation and not leaner as an artifact; it is<strong>more complete</strong>,
and it moves a decision to where it is being made anyway.</p><p>For a server hosting several applications from several teams, the answer is
the reverse. Both setups are correct; they answer different questions.</p><h2 id="outlook-on-part-4">Outlook on Part 4</h2><p>The boundary has shifted for the first time. Jetty now belongs to the
application. What is still open lies in plain sight on the server.</p><h3 id="what-this-part-cost">What This Part Cost</h3><p>The bootstrap from Chapters 4 through 10 comes to about 60 lines — and three
of them are the ones you would not write without this text:</p><ul><li><code>ForwardedRequestCustomizer</code>, otherwise the session cookie loses its<code>Secure</code> flag,</li><li>the WebSocket initialization, otherwise Push fails,</li><li>the<code>i18n.provider</code> parameter, otherwise the language switcher has no
effect.</li></ul><p>None of these three failures announces itself. Whoever writes the bootstrap
themselves takes on a duty of care that previously sat in sixteen module
files maintained by someone else.</p><h3 id="what-still-lies-next-to-it">What Still Lies Next to It</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/
├── app.jar 8,908 bytes — one class
└── lib/ 127 archives, 29.74 MB</code></pre></div><p>An application archive of nine kilobytes and a directory with 127 libraries.
The launch command names both:</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.jar:lib/*'</span> com.svenruppert.flow.Application</span></span></code></pre></div></div><p>That works, and it is inconvenient. A deployment consists of two parts that
belong together and lie apart; a rollback has to wind both back; and whoever
passes the application on passes on a directory, not an artifact.</p><h3 id="what-part-4-makes-of-it">What Part 4 Makes of It</h3><p><strong>Bootstrap and packaging are two different decisions.</strong> This part answered
the first: who starts the server? Part 4 answers the second: how does the
result get onto the target machine?</p><p>Two answers stand against each other there:</p><table><thead><tr><th/><th/></tr></thead><tbody><tr><td><strong>Fat JAR</strong></td><td>everything in one archive. One<code>java -jar</code>, one file, one rollback</td></tr><tr><td><strong>Thin distribution</strong></td><td>application archive and libraries separate, but shipped in an orderly manner</td></tr></tbody></table><p>The chapters there: how a fat JAR is built, where it causes problems —
Chapter 12 of this part has already given a foretaste —, what a thin
distribution gains, and what releases with symlink and rollback look like on
top of it.</p><p><strong>The choice is not obvious.</strong> A fat JAR is one file and therefore
convenient; but it turns every update of a single library into a complete
rebuild, and it has a known breaking point in<code>META-INF/services</code>. A thin
distribution stays decomposable, but demands a directory that has to be
correct — as Chapter 12 showed with eleven wrong files.</p><h3 id="the-boundary-keeps-moving">The Boundary Keeps Moving</h3><table><thead><tr><th>Part</th><th>What moves into the application</th></tr></thead><tbody><tr><td>1 and 2</td><td>only the WAR</td></tr><tr><td><strong>3</strong></td><td><strong>Jetty becomes a library of the application</strong></td></tr><tr><td>4</td><td>the kind of packaging (fat JAR or thin distribution)</td></tr><tr><td>5</td><td>the Java runtime (jlink)</td></tr></tbody></table><p>At the end of Part 6, the application runs on a server with no Java
installed.</p><p><strong>Why the intermediate state deliberately looks like this:</strong> a fat JAR would
have been possible in this part. It would have obscured the question of what
distinguishes bootstrap from packaging — and precisely this distinction is
the first sentence of Part 4.</p>
]]></content:encoded><category>Java</category><category>Vaadin</category><media:content url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-3-embedded-jetty-hero.png" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-3-embedded-jetty-hero.png"/><enclosure url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-3-embedded-jetty-hero.png" type="image/jpeg" length="0"/></item><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><item><title>Vaadin Deployment on Hetzner — Part 6: Self-contained Vaadin with jlink</title><link>https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink/</link><pubDate>Thu, 24 Sep 2026 10:00:00 +0200</pubDate><author>sven.ruppert@gmail.com (Sven Ruppert)</author><dc:creator>Sven Ruppert</dc:creator><guid isPermaLink="true">https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink/</guid><description>A self-contained Vaadin distribution with jlink: 16 modules, 65 instead of 309 MB, and what the smaller runtime really changes at startup.</description><content:encoded>&lt;![CDATA[<p>Sixth and final part of the series<em>Vaadin – Deployment on Hetzner</em>. The application now brings along not only Jetty but also its own Java runtime: 16 modules instead of 68, 65 instead of 309 MB — and a module list that cannot be fully derived. All figures come from an actual run on a Debian 13 server.</p><h2 id="the-state-after-part-5">The state after Part 5</h2><p>For five parts, the boundary between application and machine has been moving in
one direction. Part 1 handed a WAR to a Jetty that someone else had installed.
Part 3 turned that Jetty into a library of the application. Part 4 packaged the
result, Part 5 shipped it and demonstrated the rollback.</p><p>What sits on the reference server after Part 5 looks like this:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>/opt/vaadinapp/
├── current -&gt; releases/2026-09-09-thin
├── logs/
└── releases/
├── 2026-09-09-thin/
│ ├── VERSION
│ ├── bin/start.sh
│ ├── app/vaadinapp.jar 16 KB
│ ├── lib/ 127 archives, 29 MB
│ └── sbom.json
└── 2026-09-09-fat/</code></pre></div><p>And next to it, outside this directory:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>/var/lib/vaadinapp/.sdkman/candidates/java/current -&gt; 26.0.2+1.1-tem 309 MB</code></pre></div><p><strong>This is the last remnant.</strong> A Java, installed and maintained by someone else,
309 MB in size, eleven times as large as the application it runs. The systemd
unit reaches it through a symlink — the same mechanism that Part 1 used for
Jetty and that Jetty has not needed since Part 3.</p><h3 id="what-this-part-changes">What this part changes</h3><figure><img src="/images/2026/09/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink-teil06-laufzeit.png" alt="The last expectation placed on the machine" loading="lazy" decoding="async"/><p><em>Figure 1: The same release, once without and once with its own runtime.</em></p><p><code>jlink</code> takes the modules of a JDK and produces a runtime image that contains
only what this one application calls. The image goes into the release, next to<code>app/</code> and<code>lib/</code>. The start script finds it there, and the server no longer
needs an installed Java.</p><p>On the reference server that means<strong>65 instead of 309 MB</strong> and<strong>16 instead of
68 modules</strong>. The image contains two executables:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>runtime/bin/
├── java
└── keytool</code></pre></div><p>No<code>javac</code>, no<code>jdeps</code>, no<code>jlink</code>, no<code>jcmd</code>, no<code>jstack</code>. What sits on the
server can start the application and manage keystores — nothing else. That is
the property Chapter 10 discusses as a security gain, and it is a by-product:
nobody removed the tools, they were never part of what the application needs.</p><h3 id="what-this-part-does-not-change">What this part does not change</h3><p>Three things stay as they are, and it is worth saying so up front.</p><p><strong>The systemd unit.</strong><code>ExecStart</code> still points to<code>current/bin/start.sh</code>. No
path, no variable, not a single line changes. The trial run on the server
executed with<code>JAVA_HOME</code> unset and found its JVM anyway.</p><p><strong>The application.</strong> No<code>module-info.java</code>, no restructuring, no new dependency.
Chapter 3 explains why that is not a coincidence but jlink&rsquo;s design.</p><p><strong>The recommendation from Part 5.</strong> Anyone who chose the fat JAR can continue
just the same; the image sits next to it, not inside it.</p><h3 id="the-code-state-for-this-part">The code state for this part</h3><p>Tag<strong><code>teil-06</code></strong>. Compared to<code>teil-04</code> there are two changes: one in the
start script, one in the assembly of the distribution. Both appear in Chapter 7.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/svenruppert/Blog-Vaadin-Deployment-on-Hetzner</span></span><span class="line"><span class="cl"><span class="nb">cd</span> Blog-Vaadin-Deployment-on-Hetzner</span></span><span class="line"><span class="cl">git checkout teil-06</span></span></code></pre></div></div><p>The module this part works in is<code>embedded-jetty</code> — it carries the<code>main()</code>
and the assembly that builds the distribution.</p><h2 id="why-a-runtime-of-your-own">Why a runtime of your own?</h2><p>The obvious answer is: because the image is smaller. It is correct and at the
same time the weakest of the three answers.</p><h3 id="size">Size</h3><p>65 instead of 309 MB is 79% less. That sounds impressive and means little in
practice: a server with a 40 GB disk does not notice the difference, and the
transfer over the network barely registers next to the 29 MB of application
libraries.</p><p>Where size does matter is a different setting — wherever the image is<strong>multiplied</strong>. A container image that carries the full JDK carries it in
every layer, in every registry, on every node. For this series that is no
argument, because no container runs here; Chapter 11 comes back to it.</p><h3 id="attack-surface">Attack surface</h3><p>A full Temurin 26 ships 68 modules, 22 from the<code>java.</code> namespace and 46 from
the<code>jdk.</code> namespace. This application calls 16 of them. The remaining<strong>52</strong> are
code that sits on the server, can be loaded, and is never needed — among them
a compiler, a scripting tool, and the entire toolchain for process
inspection.</p><p>That is not a theoretical argument. Anyone who has compromised an application
far enough to execute code finds a complete development environment on a
server with a full JDK. On one with a jlink image, they find<code>java</code> and<code>keytool</code>.</p><p>The second half of the same argument concerns vulnerability reports. A
CVE against a module that is not contained in the image does not affect this
release — verifiably so, not as a matter of judgment. Chapter 10 shows how
to prove it.</p><h3 id="the-division-of-responsibility">The division of responsibility</h3><p>This is the answer that carries this series.</p><p>As long as a Java is installed on the server, there are two places where
decisions about the application&rsquo;s runtime are made: the release and the machine.
A release built and tested with Java 26 runs on a machine whose Java someone
has raised to 27. Maybe it works. Nobody has verified it, because the two
decisions were made in different places.</p><p>With its own image, the Java version is<strong>part of the release</strong> and travels with
it — forward on update, backward on rollback. The rollback from Part 5,
Chapter 5 takes the runtime along without anything having to change for it.</p><p>It is the same idea that carried Part 3. There it was about Jetty: a server
whose Jetty someone else maintains has a second place where decisions about
the application&rsquo;s behavior are made. Part 6 applies it to the last remaining
component.</p><h3 id="what-speaks-against-it">What speaks against it</h3><p>Three things, and they are not small.</p><p><strong>The build becomes platform-bound.</strong> An image for Linux x86-64 can only be
produced on Linux x86-64. Chapter 6 describes what that means for the build
process and concedes that the solution on the reference server is a compromise.</p><p><strong>Java updates become releases.</strong> A security update of the JDK used to be<code>sdk install</code>,<code>sdk default</code>, restart — three steps without a new build. With
your own image it is a new release. Chapter 10 works through the numbers.</p><p><strong>The module list has to be right.</strong> And it cannot be fully derived.
That is the core of Chapters 4 and 5 and the actual reason this part is
longer than the tool suggests.</p><h2 id="what-jlink-does--and-what-it-does-not-require">What jlink does — and what it does not require</h2><p>A misconception circulates about<code>jlink</code> that is persistent enough to derail
entire projects: that you have to modularize your application to use
it. That is wrong, and the distinction is worth clearing up before the first
line of tool invocation appears.</p><h3 id="two-class-paths-that-have-nothing-to-do-with-each-other">Two class paths that have nothing to do with each other</h3><p>Since Java 9 there have been two ways for the JVM to find classes.</p><p>The<strong>module path</strong> (<code>--module-path</code>) carries modules: archives with a<code>module-info.class</code> that declare what they export and what they require.
The<strong>class path</strong> (<code>-cp</code>) carries everything else — it works the way it has
worked since Java 1.0, and everything that sits on it ends up in the so-called
unnamed module.</p><p><code>jlink</code> operates exclusively on the first. It builds an image from<strong>platform modules</strong> —<code>java.base</code>,<code>java.sql</code>,<code>jdk.zipfs</code>, and so on. What the
application brings along does not interest it.</p><p>And that is exactly why nothing changes about the application:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">sh</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">exec</span><span class="s2">"</span><span class="nv">$JAVA_BIN</span><span class="s2">"</span><span class="nv">$APP_OPTS</span><span class="nv">$JAVA_OPTS</span> -cp<span class="s2">"</span><span class="nv">$CLASSPATH</span><span class="s2">"</span> com.svenruppert.flow.Application<span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span></span></span></code></pre></div></div><p>That is the start line from Part 4, unchanged. The class path still points to<code>app/*:lib/*</code>, the 127 archives still carry no<code>module-info.class</code>, and the
JVM still loads them as the unnamed module. The only thing that changes is<strong>which JVM</strong> that is.</p><div class="pull-quote"><p><strong>The sentence that matters:</strong><code>jlink</code> trims the platform, not the
application. An image of 16 modules runs a class path of 127
non-modular archives without any of them knowing about it.</p></div><h3 id="why-the-story-is-often-told-differently">Why the story is often told differently</h3><p>Because<code>jlink</code> demands a module list, and the obvious way to obtain a
complete module list is to make the application itself modular.
Then<code>module-info.java</code> writes down what it needs, and<code>jlink</code> can follow that declaration.</p><p>Anyone who does not modularize the application — and with 127 dependencies,
nobody does — has to determine the list some other way. That is what<code>jdeps</code> is
for, that is what Chapter 4 is for, and that is what the uncomfortable finding
in Chapter 5 is for.</p><p><strong>So the effort does not disappear, it shifts.</strong> You save yourself the
restructuring of the application and pay with a list that has to be maintained.</p><h3 id="what-ends-up-in-the-image">What ends up in the image</h3><p><code>jlink</code> takes the named modules, resolves their<code>requires</code> relationships, and
writes the result out as a runtime image. On the reference server, 10 named
modules become 16:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>java.base java.datatransfer java.desktop
java.instrument java.logging java.management
java.naming java.net.http java.prefs
java.security.jgss java.security.sasl java.sql
java.transaction.xa java.xml jdk.unsupported
jdk.zipfs</code></pre></div><p>The six unnamed ones come in transitively:<code>java.desktop</code> requires<code>java.datatransfer</code>,<code>java.sql</code> requires<code>java.transaction.xa</code> and<code>java.xml</code>,<code>java.security.jgss</code> requires<code>java.security.sasl</code>. You name the roots; the
resolver takes care of the rest.</p><p><code>java.desktop</code> in a server application looks like a mistake but is
correct: that is where<code>java.beans</code> lives, and<code>flow-server</code>,<code>flow-data</code>,
and Jetty&rsquo;s servlet layer use it. Chapter 4 shows how to verify that.</p><h3 id="what-does-not-end-up-in-the-image">What does not end up in the image</h3><p>Everything else — and that is more than the number 60 suggests:</p><table><thead><tr><th>missing</th><th>what no longer works</th></tr></thead><tbody><tr><td><code>jdk.compiler</code></td><td>compiling at run time</td></tr><tr><td><code>jdk.jshell</code>,<code>jdk.scripting.nashorn</code></td><td>running scripts</td></tr><tr><td><code>jdk.attach</code>,<code>jdk.jcmd</code>,<code>jdk.jfr</code></td><td>attaching to other JVMs, Flight Recorder</td></tr><tr><td><code>java.rmi</code>,<code>jdk.jdi</code></td><td>remote calls, remote diagnostics</td></tr><tr><td><code>jdk.jlink</code>,<code>jdk.jdeps</code></td><td>building another image from this one</td></tr></tbody></table><p>The last row has a practical consequence that Chapter 6 runs into on the
reference server:<strong>a jlink image cannot reproduce itself.</strong> Whoever has the
image cannot build a new one; that takes the JDK.</p><p>The third row is the one you miss before you need it. Anyone used to tackling
a hanging service with<code>jcmd</code> or<code>jstack</code> will not find the tool
in the image.</p><p>It can be retrofitted, and the price is lower than caution suggests.
With<code>jdk.jcmd</code> added to the module list:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>without jdk.jcmd 65,992 KB 16 modules bin: java keytool
with jdk.jcmd 66,292 KB 19 modules bin: java jcmd jinfo jmap jps jstack jstat keytool</code></pre></div><p><strong>300 KB for six tools.</strong> That is not a question of size but a trade-off
between diagnostic capability and attack surface — and it is one to decide
deliberately, not to catch up on during the first incident. For the reference
server it stays at the 16 modules: whatever needs diagnosing is in the
journal, and a release without diagnostic tools can, if in doubt, be swapped
for one with them — that is what the symlink from Part 5 is for.</p><h2 id="determining-the-required-platform-modules">Determining the required platform modules</h2><p><code>jlink</code> demands a list. The application does not keep one. So someone has to
produce it — and the tool for that is called<code>jdeps</code>.</p><h3 id="the-invocation">The invocation</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">jdeps --print-module-deps<span class="se">\</span></span></span><span class="line"><span class="cl"> --ignore-missing-deps<span class="se">\</span></span></span><span class="line"><span class="cl"> --multi-release<span class="m">26</span><span class="se">\</span></span></span><span class="line"><span class="cl"> --class-path<span class="s2">"current/lib/*"</span><span class="se">\</span></span></span><span class="line"><span class="cl"> current/app/vaadinapp.jar</span></span></code></pre></div></div><p>Four switches, and three of them need explaining.</p><p><strong><code>--print-module-deps</code></strong> switches the output from a report to a
list — exactly the form<code>--add-modules</code> expects. Without this switch
you get a multi-page breakdown of who calls whom; with it, a single line.</p><p><strong><code>--ignore-missing-deps</code></strong> is unavoidable on a real class path. 127
archives practically always contain references to classes that are not
shipped — optional integrations with libraries you do not use. Without
this switch,<code>jdeps</code> aborts at the first such spot instead of evaluating
the rest.</p><p>That is the first hint at how this analysis works: it is<strong>allowed</strong> to
have gaps, and it does not tell you so.</p><p><strong><code>--multi-release 26</code></strong> determines which branch of a multi-release
archive<code>jdeps</code> reads. Without it, it refuses to work on every archive with<code>Multi-Release: true</code> in its manifest — and that is the same
manifest entry that Part 4, Chapter 5 dealt with for the fat JAR.</p><h3 id="the-result">The result</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>java.base,java.desktop,java.instrument,java.management,java.naming,java.security.jgss,java.sql</code></pre></div><p>Seven modules. Anyone who does not want to take them on faith can have<code>jdeps</code>
show, in report mode, which archive pulls in which module — here the most
notable culprits for each:</p><table><thead><tr><th>Module</th><th>pulled in by</th></tr></thead><tbody><tr><td><code>java.base</code></td><td>everything</td></tr><tr><td><code>java.desktop</code></td><td><code>flow-server</code>,<code>flow-data</code>,<code>jakarta.el</code>, Jetty&rsquo;s servlet layer</td></tr><tr><td><code>java.instrument</code></td><td><code>org.eclipse.jetty.ee.webapp</code></td></tr><tr><td><code>java.management</code></td><td><code>org.eclipse.jetty.util</code>,<code>org.eclipse.serializer.base</code></td></tr><tr><td><code>java.naming</code></td><td><code>org.eclipse.jetty.jndi</code>,<code>…ee11.annotations</code>, BouncyCastle</td></tr><tr><td><code>java.security.jgss</code></td><td><code>org.eclipse.jetty.client</code>,<code>org.eclipse.jetty.security</code></td></tr><tr><td><code>java.sql</code></td><td><code>flow-data</code>,<code>org.eclipse.jetty.plus</code>, BouncyCastle</td></tr></tbody></table><p>You take these seven, hand them to<code>jlink</code>, get an image, start it — and
get an error.</p><h3 id="the-first-failure">The first failure</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>java.nio.file.ProviderNotFoundException: Provider "jar" not found
at java.base/java.nio.file.FileSystems.newFileSystem(FileSystems.java:341)
at org.eclipse.jetty...</code></pre></div><p>The missing module is<code>jdk.zipfs</code>. Jetty opens archives not only as files
but as<strong>file systems</strong> —<code>FileSystems.newFileSystem(uri, env)</code> with a<code>jar:</code> URI. The provider for that lives in<code>jdk.zipfs</code>, is found via<code>ServiceLoader</code>, and appears in no<code>import</code> statement.</p><p>Added, rebuilt, started — and the next error.</p><h3 id="the-second-failure">The second failure</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>java.lang.NoClassDefFoundError: sun/misc/Unsafe
at org.eclipse.store...</code></pre></div><p><code>jdk.unsupported</code>. EclipseStore uses<code>sun.misc.Unsafe</code> to read and write
objects bypassing serialization — that is the reason it is fast. The module
is called &ldquo;unsupported&rdquo; because it contains access points that are
officially not part of the platform; in the full JDK it is always present anyway,
which is why its absence only shows up here.</p><p>Added, rebuilt, started — and this time it runs.</p><h3 id="nine-modules-one-running-service">Nine modules, one running service</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>systemctl status vaadinapp active (running)
curl -o /dev/null -w '%{http_code}' https://demo.svenruppert.com/ 200
curl -o /dev/null -w '%{http_code}' https://demo.svenruppert.com/login 200</code></pre></div><p>The service runs. Both routes respond. Login works, language
switching works, persistence works.</p><p>This is where the part would end — if the image were complete. It
is not, and the fact that it runs anyway is the most interesting finding of
this series.</p><h2 id="the-three-modules-jdeps-cannot-see">The three modules<code>jdeps</code> cannot see</h2><p>Two modules were supplied by the trial run, each with an error at startup.
The third produces no error.</p><h3 id="what-is-missing">What is missing</h3><p><code>java.net.http</code>. The module with the HTTP client that Java 11 introduced. It
does not appear in the<code>jdeps</code> output, it is missing from the image, and the
service starts, runs, and serves as if everything were fine.</p><h3 id="why-jdeps-does-not-find-it">Why<code>jdeps</code> does not find it</h3><p>Not because of reflection. The call is right there in the code:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="kd">class</span><span class="nc">HibpHolder</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">static</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="n">HaveIBeenPwnedCompromisedPasswordChecker</span><span class="w"/><span class="n">INSTANCE</span><span class="w"/><span class="o">=</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">HaveIBeenPwnedCompromisedPasswordChecker</span><span class="p">.</span><span class="na">usingJdkHttpClient</span><span class="p">(</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">HaveIBeenPwnedCompromisedPasswordChecker</span><span class="p">.</span><span class="na">DEFAULT_ENDPOINT</span><span class="p">,</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">HIBP_TIMEOUT</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>The reason lies elsewhere, and it is a property of the invocation from Chapter 4
that is easy to overlook:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">jdeps ... --class-path<span class="s2">"current/lib/*"</span> current/app/vaadinapp.jar</span></span></code></pre></div></div><p><strong><code>jdeps</code> examines what is given as an argument. The class path serves only
for resolution.</strong> The argument is exactly one archive:<code>app/vaadinapp.jar</code>, 16 KB,
essentially the launcher class. The 127 archives in<code>lib/</code> are consulted to
resolve references — they are not examined.</p><p>And the chain to the HTTP client runs entirely through<code>lib/</code>:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>app/vaadinapp.jar
└─&gt; jCustos-…-core (lib/) application code
└─&gt; PasswordPreflight (lib/) der Lazy Holder oben
└─&gt; jCustos-credentials-hibp-00.83.00.jar (lib/)
└─&gt; java.net.http</code></pre></div><p>Nothing is wrong with the tool, it reports no error, and it answers exactly the
question it was asked.</p><h4 id="the-obvious-switch-does-not-help">The obvious switch does not help</h4><p><code>jdeps</code> has<code>-R</code> for recursive traversal. Measured on the same release:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>without -R: java.base,java.desktop,java.instrument,java.management,
java.naming,java.security.jgss,java.sql
with -R: java.base,java.desktop,java.instrument,java.management,
java.naming,java.security.jgss,java.sql</code></pre></div><p><strong>Identical.</strong> In combination with<code>--print-module-deps</code> and<code>--ignore-missing-deps</code>, the switch changes nothing about the result.</p><h4 id="all-archives-as-arguments--and-the-next-problem">All archives as arguments — and the next problem</h4><p>The way that remains: have every archive examined, not just the one.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">jdeps --print-module-deps --ignore-missing-deps --multi-release<span class="m">26</span><span class="se">\</span></span></span><span class="line"><span class="cl"> current/app/vaadinapp.jar current/lib/*.jar</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>java.base, java.desktop, java.instrument, java.management, java.naming,
java.net.http, java.rmi, java.security.jgss, java.sql, jdk.unsupported</code></pre></div><p>Ten instead of seven.<code>java.net.http</code> is in,<code>jdk.unsupported</code> too. That looks
like the solution, and it is not — for two reasons.</p><p><strong><code>java.rmi</code> is too much.</strong> The shipped image does not contain it and runs.
The culprit is<code>jakarta.transaction</code> — the interface for distributed
transactions references remote calls, and this application never enters that
branch. Anyone who follows this list adds a module that contributes nothing but
attack surface.</p><p><strong><code>jdk.zipfs</code> is still missing.</strong> It appears in neither of the two outputs,
because it is not the target of any call: Jetty requests a file system for a<code>jar:</code> URI, and which provider serves it is decided at run time via<code>ServiceLoader</code>. Nothing of that is in the compiled code.</p><div class="pull-quote"><p>So the exhaustive variant produces a list that is<strong>too large and too small
at the same time</strong>. That is exactly what makes it dangerous: it looks complete.</p></div><h3 id="how-the-absence-shows-itself">How the absence shows itself</h3><p>An image without<code>java.net.http</code>, started against the same libraries,
password check invoked directly:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>### Image WITHOUT java.net.http (15 modules)
password123 -&gt; acceptable=false
correct-horse-battery-staple-42 -&gt; java.lang.NoClassDefFoundError:
java/net/http/HttpTimeoutException
### Image WITH java.net.http (the shipped one, 16 modules)
password123 -&gt; acceptable=false
correct-horse-battery-staple-42 -&gt; acceptable=true</code></pre></div><p>Three observations, each more unpleasant than the last.</p><p><strong>The first line is the same in both cases.</strong> A weak password is
rejected, with and without the module — the local blocklist runs before the
network query and needs no HTTP. Anyone who tests the check with an obviously
bad password sees<strong>no</strong> problem.</p><p><strong>The error does not name the class you expect.</strong> Not<code>HttpClient</code>
but<code>HttpTimeoutException</code> — the class the loading process stumbles over
first. Anyone who searches for the module name will not find it in the error text.</p><p><strong>The timing is the worst imaginable.</strong> The service starts, both
routes respond with 200, login works. The release is declared good.
It breaks the first time someone sets a password — on a fresh
installation that is the setup screen, that is, the very first action of the
very first user.</p><h3 id="what-follows-from-this">What follows from this</h3><p><strong>A startup attempt is not a proof of completeness.</strong> It demonstrates that the
modules suffice for startup, and it says nothing about anything loaded
later.</p><p><strong>The module list needs a test that uses the application</strong> — not
starts it: uses it. On the reference server, that was setting a password. What
such a run does not touch, nobody checks.</p><p><strong>The list belongs documented, not derived.</strong> In the build script it
therefore appears not as the output of a tool but as text with a failure signature:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">sh</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="c1"># What jdeps cannot see. Every entry comes from an actual run, not from</span></span></span><span class="line"><span class="cl"><span class="c1"># an analysis:</span></span></span><span class="line"><span class="cl"><span class="c1">#</span></span></span><span class="line"><span class="cl"><span class="c1"># jdk.zipfs ProviderNotFoundException at startup. Jetty opens</span></span></span><span class="line"><span class="cl"><span class="c1"># archives as file systems.</span></span></span><span class="line"><span class="cl"><span class="c1"># jdk.unsupported NoClassDefFoundError: sun/misc/Unsafe. EclipseStore.</span></span></span><span class="line"><span class="cl"><span class="c1"># java.net.http No error. The image starts, serves pages, and logs</span></span></span><span class="line"><span class="cl"><span class="c1"># users in; the HIBP check only breaks when someone</span></span></span><span class="line"><span class="cl"><span class="c1"># sets a password.</span></span></span><span class="line"><span class="cl"><span class="nv">EXTRA</span><span class="o">=</span><span class="s2">"jdk.zipfs,jdk.unsupported,java.net.http"</span></span></span></code></pre></div></div><p>A comment that names a failure signature is worth more than a list that is
correct. The list goes stale with the next dependency; the failure signature
tells the next person what to look for.</p><h3 id="the-finding-that-holds-the-series-together">The finding that holds the series together</h3><p>It is the third time, and it is the same shape every time.</p><p>In<strong>Part 3</strong> it was the class path: what the compiler sees is not enough for
the annotation scan. In<strong>Part 4</strong> it was the provider directory: what the
compiler sees is not enough for the merge. In<strong>Part 6</strong> it is the
module list.</p><div class="pull-quote"><p><strong>Decisions made at build time about run time have to go beyond what the
compiler sees.</strong> Java loads at run time through reflection, through<code>ServiceLoader</code>, through names in text files. Every tool that predicts run
time from compiled code has its limit exactly here — and none of them tells
you where it lies.</p></div><h2 id="producing-the-image">Producing the image</h2><p>The module list is settled. The invocation that turns it into an image is
short — and every one of its switches has a reason you should know before
adopting it.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">jlink --add-modules<span class="s2">"</span><span class="nv">$MODULES</span><span class="s2">"</span><span class="se">\</span></span></span><span class="line"><span class="cl"> --output<span class="s2">"</span><span class="nv">$DIST</span><span class="s2">/runtime"</span><span class="se">\</span></span></span><span class="line"><span class="cl"> --strip-java-debug-attributes<span class="se">\</span></span></span><span class="line"><span class="cl"> --no-header-files<span class="se">\</span></span></span><span class="line"><span class="cl"> --no-man-pages<span class="se">\</span></span></span><span class="line"><span class="cl"> --compress zip-6</span></span></code></pre></div></div><h3 id="the-switches">The switches</h3><p><strong><code>--strip-java-debug-attributes</code></strong> removes line numbers and local
variable names from the class files of the platform. This affects the
platform classes exclusively; stack traces of the<strong>application</strong> keep their
line numbers, because the application archives in<code>lib/</code> remain untouched. What
you lose are line numbers in JDK-internal frames — bearable when debugging.</p><p><strong><code>--no-header-files</code></strong> and<strong><code>--no-man-pages</code></strong> remove the C header files for
JNI and the manual pages. Nobody needs either on a server.</p><p><strong><code>--compress zip-6</code></strong> compresses the module store. Chapter 9 measures what it
costs and what it brings; the short version is 33 MB less image at
identical startup time.</p><h3 id="-the-switch-that-fails-on-a-lean-debian">⚠️ The switch that fails on a lean Debian</h3><p>The documentation and most tutorials name<code>--strip-debug</code>. On the
reference server it ends like this:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>Error: java.io.IOException: Cannot run program "objcopy":
Exec failed, error: 2 (No such file or directory)</code></pre></div><p><code>--strip-debug</code> invokes an<strong>external program</strong> —<code>objcopy</code> from the GNU
binutils — to remove the symbol tables from the native libraries. On
a development machine it is present, on a minimal
Debian installation it is not.</p><p>Two ways out:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt-get install binutils<span class="c1"># about 20 MB of tooling on the server</span></span></span></code></pre></div></div><p>or the switch that manages without an external program:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">--strip-java-debug-attributes<span class="c1"># does the same inside the JVM</span></span></span></code></pre></div></div><p>On a server that in the end is not supposed to carry any Java at all, it would
be absurd to install binutils so that<code>jlink</code> can run. That is why the build
script contains the second variant.</p><h3 id="where-the-build-takes-place">Where the build takes place</h3><p>Here lies the most uncomfortable property of the whole approach.</p><p><strong>A jlink image is platform-bound.</strong> An image for Linux x86-64 is produced
on Linux x86-64. Anyone who develops on a Mac with Apple silicon — as in
this series — cannot build it on their machine.</p><p>The documented way out is called cross-linking: download a second, complete JDK
for the<strong>target platform</strong> and point<code>jlink</code> via<code>--module-path</code> at its<code>jmods</code> directory. That works and is the right way as soon as a build
machine is involved.</p><p>On the reference server it fails over a detail:<strong>the Temurin build installed
by SDKMAN ships no<code>jmods</code> directory.</strong> Cross-linking would
therefore require a second, complete download — for a platform on
which the build could run anyway.</p><p>So the server builds its image itself. This is possible thanks to<strong>JEP 493</strong>
(<em>Linking Run-Time Images without JMODs</em>, introduced with Java 24):<code>jlink</code>
works from the installed runtime image and no longer needs the<code>jmods</code> files. Without this mechanism, the path on this
server would be blocked.</p><div class="pull-quote"><p><strong>This is a compromise, and let it be named as one.</strong> Part 1, Step 5
argued explicitly for separating build and runtime environments. A
build step that runs on the target machine violates that separation. With a
Linux build machine it would belong there — and with a CI run under Linux it
belongs there anyway. What is shown here is the way for the case
where that machine does not exist.</p></div><h3 id="the-script">The script</h3><p>The whole process lives in<code>tools/jlink-runtime.sh</code>, invoked with the
unpacked distribution directory:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">tools/jlink-runtime.sh /opt/vaadinapp/releases/2026-09-12-jlink</span></span></code></pre></div></div><p>It determines the modules with<code>jdeps</code>, adds the three from Chapter 5, invokes<code>jlink</code>, and reports at the end what was produced:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>jlink-runtime: jdeps found 7, adding 3 it cannot see
jlink-runtime: 65M, 16 modules</code></pre></div><p>That line is worded that way on purpose. Whoever reads it sees immediately that
three modules do not come from the analysis — and knows where to look if the
image later turns out to be missing something.</p><h2 id="merging-image-and-distribution">Merging image and distribution</h2><p>The image exists, the distribution from Part 4 does too. Together they form a
release that expects nothing more from the machine. Two changes are needed
for that — one to the start script, one to the assembly.</p><h3 id="the-shape-of-the-release">The shape of the release</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>2026-09-12-jlink/
├── VERSION
├── bin/start.sh
├── app/vaadinapp.jar 16 KB
├── lib/ 127 archives, 29 MB
├── runtime/ 16 modules, 65 MB ← new
└── sbom.json</code></pre></div><p><code>runtime/</code> joins<code>app/</code> and<code>lib/</code>, it does not replace them. Everything Parts 4
and 5 said about this shape holds unchanged: same unit,
same symlink, same rollback.</p><h3 id="the-change-to-the-start-script">The change to the start script</h3><p>Part 4 introduced the start script with the sentence that the release itself
says how it wants to be started. That is exactly where the decision about the
JVM belongs:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">sh</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="k">if</span><span class="o">[</span> -x<span class="s2">"</span><span class="nv">$APP_HOME</span><span class="s2">/runtime/bin/java"</span><span class="o">]</span><span class="p">;</span><span class="k">then</span></span></span><span class="line"><span class="cl"><span class="nv">JAVA_BIN</span><span class="o">=</span><span class="s2">"</span><span class="nv">$APP_HOME</span><span class="s2">/runtime/bin/java"</span></span></span><span class="line"><span class="cl"><span class="k">else</span></span></span><span class="line"><span class="cl"><span class="nv">JAVA_BIN</span><span class="o">=</span><span class="si">${</span><span class="nv">JAVA_HOME</span><span class="p">:+</span><span class="nv">$JAVA_HOME</span><span class="p">/bin/java</span><span class="si">}</span></span></span><span class="line"><span class="cl"><span class="nv">JAVA_BIN</span><span class="o">=</span><span class="si">${</span><span class="nv">JAVA_BIN</span><span class="k">:-</span><span class="nv">java</span><span class="si">}</span></span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div></div><p>Four lines, and they accomplish more than they appear to.</p><p><strong>A release with its own runtime uses it.</strong> Without configuration, without an
environment variable, without the unit knowing anything about it.</p><p><strong>A release without its own runtime behaves as before.</strong><code>JAVA_HOME</code>, otherwise<code>java</code> from the path — that is literally the version from Part 4.</p><p><strong>And both sit side by side in the same<code>releases/</code> directory.</strong> The
switch between them is the same<code>ln -sfn</code> as any other. That is the
reason the changeover was possible without downtime and the rollback stays
open.</p><p>The proof of that is short and unambiguous: the trial run on the server executed with<strong><code>JAVA_HOME</code> unset</strong> and found its JVM anyway.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>ExecStart=/opt/vaadinapp/current/bin/start.sh</code></pre></div><p>This line was in the unit before the changeover and remains in it, unchanged,
afterwards.</p><h3 id="the-change-to-the-assembly">The change to the assembly</h3><p>The assembly descriptor from Part 4 gets one more<code>fileSet</code>:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">xml</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;fileSet&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;directory&gt;</span>${project.build.directory}/jlink-runtime<span class="nt">&lt;/directory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;outputDirectory&gt;</span>runtime<span class="nt">&lt;/outputDirectory&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;fileMode&gt;</span>0644<span class="nt">&lt;/fileMode&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;directoryMode&gt;</span>0755<span class="nt">&lt;/directoryMode&gt;</span></span></span><span class="line"><span class="cl"><span class="nt">&lt;/fileSet&gt;</span></span></span></code></pre></div></div><p>If an image sits under<code>target/jlink-runtime</code>, it goes into the distribution.
If none is there, the assembly plugin skips the directory
without comment, and the same thin distribution as in Part 4 is produced.</p><p><strong>The Maven build deliberately does not produce the image itself.</strong> If it did,
a development machine would produce an image for that machine, and it would go
into an archive destined for a Linux server — a mistake that would only surface
on the server, and there with a less than helpful<code>Exec format error</code>.</p><p>Instead, the division of labor is:</p><table><thead><tr><th>who</th><th>what</th></tr></thead><tbody><tr><td>Maven</td><td>builds the application, packs<code>app/</code>,<code>lib/</code>,<code>bin/</code>,<code>sbom.json</code></td></tr><tr><td><code>tools/jlink-runtime.sh</code></td><td>builds the image — where it is meant to run</td></tr><tr><td>Assembly</td><td>picks the image up,<strong>if</strong> it is there</td></tr></tbody></table><p>On a Linux build machine all three steps run back to back in the same
pass, and the finished<code>.tar.gz</code> already carries the runtime. Without such a
machine, the middle step runs on the target machine — Chapter 8 shows how.</p><h3 id="what-happens-to-the-sbom">What happens to the SBOM</h3><p><code>sbom.json</code> still describes the application: 129 Java components, 118
JavaScript components.<strong>The Java runtime is not in it.</strong></p><p>That is a gap, and it must be named. Whoever ships the runtime ships
software that until now someone else was responsible for — and the bill of
materials says nothing about it. An image of 16 modules from Temurin 26.0.2.1 is a
component with a version and a provenance, and it belongs on the list.</p><p>The<code>cyclonedx-maven-plugin</code> does not pick it up, because it does not exist at
the build time of the Maven run. It can be added by hand, and what that
looks like belongs in its own context — the two articles on SBOMs and
delivery formats take it up.</p><p>For this part, the finding stands:<strong>the release becomes more complete, its
bill of materials does not.</strong></p><h2 id="getting-it-onto-the-server">Getting it onto the server</h2><p>The procedure is the one from Part 5, with one step inserted.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1 Build and upload — unchanged from Part 5</span></span></span><span class="line"><span class="cl">mvn -Pproduction,thin clean package</span></span><span class="line"><span class="cl">scp embedded-jetty/target/vaadinapp-00.01.00-thin.tar.gz sven@server:/tmp/</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 2 Unpack next to the existing releases</span></span></span><span class="line"><span class="cl"><span class="nv">REL</span><span class="o">=</span>/opt/vaadinapp/releases/2026-09-12-jlink</span></span><span class="line"><span class="cl">sudo mkdir -p<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span></span></span><span class="line"><span class="cl">sudo tar xzf /tmp/vaadinapp-00.01.00-thin.tar.gz --strip-components<span class="o">=</span><span class="m">1</span> -C<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 3 NEW: add the runtime</span></span></span><span class="line"><span class="cl">sudo<span class="nv">JAVA_HOME</span><span class="o">=</span>/var/lib/vaadinapp/.sdkman/candidates/java/current<span class="se">\</span></span></span><span class="line"><span class="cl"> sh /opt/vaadinapp/tools/jlink-runtime.sh<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 4 Ownership — as for every release</span></span></span><span class="line"><span class="cl">sudo chown -R root:vaadinapp<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span></span></span><span class="line"><span class="cl">sudo chmod -R g-w,o-rwx<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 5 Switch over and restart — unchanged from Part 5</span></span></span><span class="line"><span class="cl">sudo ln -sfn<span class="s2">"</span><span class="nv">$REL</span><span class="s2">"</span> /opt/vaadinapp/current</span></span><span class="line"><span class="cl">sudo systemctl restart vaadinapp</span></span></code></pre></div></div><p>Step 3 is the only addition. Steps 1, 2, 4, and 5 are literally the ones
from Part 5, Chapter 4.</p><h3 id="the-result-1">The result</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>current -&gt; releases/2026-09-12-jlink
JVM /opt/vaadinapp/current/runtime/bin/java
Service active (running)
HTTP 200
Hardening 1.1 OK</code></pre></div><p><strong>The hardening score is unchanged.</strong> That is not self-evident: the
runtime now sits below<code>/opt/vaadinapp</code>, that is, in a
directory the service reads. It is owned by<code>root:vaadinapp</code> and is not
writable for the group — the same rule that Pitfall 12 from Part 1 enforced for
the SDKMAN directory, applied here to the release. The service
can execute its JVM and cannot replace it.</p><p><code>ReadWritePaths</code> stays at<code>/var/lib/vaadinapp/work</code> and<code>…/logs</code>. The release
is immutable for the service, runtime included.</p><h3 id="what-the-changeover-cost">What the changeover cost</h3><p>Nothing beyond an ordinary release switch. The service was
unreachable for the duration of one restart; the number is in Chapter 9.
Caddy held the connections during that time; the public response stayed
at 200, because access runs through the reverse proxy and not directly against
port 8080.</p><h3 id="-the-jdk-cannot-go-yet">⚠️ The JDK cannot go yet</h3><p>The obvious next move would be to remove the SDKMAN JDK. It would be
premature.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>/opt/vaadinapp/releases/
├── 2026-09-09-thin/ needs the installed JDK
├── 2026-09-09-fat/ needs the installed JDK
└── 2026-09-12-jlink/ brings its own runtime</code></pre></div><p>Only the newest release is self-contained. The two predecessors fall back to
the installed JDK via<code>JAVA_HOME</code> — and precisely they are the target of a
rollback. Removing the JDK would mean giving up the rollback, and with it
the property that Part 5 singled out as the most important.</p><div class="pull-quote"><p><strong>It becomes a server without an installed Java only when no release
needs one anymore.</strong> The gain lies not in building the image but in the clearing
away afterwards — and that moment comes not with the first jlink release, but
with the last runtime-less release that you still want to keep
available for a rollback.</p></div><p>For the reference server that means: the 309 MB stay put for now. Anyone who
wants to be rid of them keeps the predecessors as<code>.tar.gz</code> in an archive
instead of as unpacked releases — then a rollback is one extra unpack, and the
JDK can go.</p><h3 id="the-build-step-on-the-target-machine-once-more">The build step on the target machine, once more</h3><p>Step 3 invokes a compiler component on a production server. That is
the spot where this chapter is unclean, and let it be named clearly:</p><ul><li>A<strong>full JDK</strong> must sit on the server so that<code>jlink</code> and<code>jdeps</code> are
available — the very JDK you wanted to get rid of.</li><li>The step runs as<code>root</code>.</li><li>It is not repeatable in the sense that two runs on two servers would
produce the same image; they produce two images from two JDK states.</li></ul><p><strong>The clean way is a build machine running Linux.</strong> There, steps 1
and 3 run together, the<code>.tar.gz</code> already carries the runtime, and what remains
on the server is unpacking and switching over. The server then needs no JDK — and
step 3 disappears there entirely.</p><p>For this series that machine does not exist, and rather than pretending it
does, what stands here is the way that works without it.</p><h2 id="measuring-what-a-runtime-of-your-own-costs">Measuring: what a runtime of your own costs</h2><p>Four variants, three runs each, all on the same server, all with the same
application and the same unit.</p><table><thead><tr><th>Variant</th><th>Image</th><th>Downtime</th><th>Uptime</th><th><code>MemoryCurrent</code></th><th>RSS</th></tr></thead><tbody><tr><td>Thin distribution, full JDK</td><td>309 MB</td><td>4,653 ms</td><td><strong>3,574 ms</strong></td><td><strong>358 MiB</strong></td><td>382 MiB</td></tr><tr><td><strong>jlink,<code>zip-6</code></strong><em>(shipped)</em></td><td><strong>65 MB</strong></td><td>4,886 ms</td><td>3,866 ms</td><td>370 MiB</td><td>401 MiB</td></tr><tr><td>jlink, no compression</td><td>98 MB</td><td>4,763 ms</td><td>3,852 ms</td><td>373 MiB</td><td>393 MiB</td></tr><tr><td>jlink,<code>zip-6</code> + CDS</td><td>93 MB</td><td>4,895 ms</td><td>3,899 ms</td><td><strong>359 MiB</strong></td><td>392 MiB</td></tr></tbody></table><p><em>Downtime</em> is the span during which the service does not respond on port 8080.<em>Uptime</em> is reported by the application itself, measured from process start to
Jetty&rsquo;s operational readiness.</p><h3 id="size-1">Size</h3><p><strong>79% less</strong> — 65 instead of 309 MB. That is the number that convinces, and
Chapter 2 has already said why it weighs the least in this setup: the
server has the space.</p><p>More interesting is the ratio inside the release. At 65 MB, the runtime is<strong>more than twice the size of the application</strong> (29 MB of<code>lib/</code> plus 16 KB of<code>app/</code>). Whoever ships a release that carries its own runtime ships
two-thirds Java.</p><h3 id="startup-takes-longer">Startup takes longer</h3><p>And that was not expected.</p><p><strong>Around 290 ms</strong> more uptime, 3,866 versus 3,574 ms. Reproducible across all
runs, clearly outside the noise. Two explanations suggested themselves, both
were tested,<strong>both are refuted</strong>:</p><p><strong>The compression.</strong><code>zip-6</code> has to be decompressed on loading — that
plausibly costs time. Measured: the uncompressed image (98 MB) lowers the
downtime by 123 ms and leaves<strong>the uptime unchanged</strong> at 3,852 ms. So the
compression costs nothing in application startup time.</p><p><strong>The missing class-data archive.</strong> A full Temurin ships four prepared
CDS archives; a jlink image does not. That is more than a guess — it
is right there in the version output:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>full JDK: ... (build 26.0.2.1+1, mixed mode, sharing)
jlink image: ... (build 26.0.2.1+1, mixed mode)</code></pre></div><p>The<code>sharing</code> is missing. Measured with<code>--generate-cds-archive</code>: the uptime
even rises slightly, to 3,899 ms.<strong>That is not it either.</strong></p><div class="pull-quote"><p><strong>The cause remains open.</strong> That is an unsatisfying sentence at the end of a
measurement chapter, and it is preferable to the alternative: writing down
one of the two refuted explanations anyway because it sounds plausible.
Whoever knows the cause, please say so.</p></div><p>The number still deserves context: 290 ms on 3.5 seconds is 8%, and it
is incurred by a service that starts once per release. For this setup
it is irrelevant. For an application that starts per invocation it would be the
opposite — and there the tool of choice would not be jlink anyway, but a
native image.</p><h3 id="memory">Memory</h3><p>The jlink image needs<strong>12 MiB more</strong>, 370 versus 358 MiB. Same application,
same settings, fewer modules — and more memory.</p><p>Here the CDS explanation that failed for startup time does apply: with<code>--generate-cds-archive</code> the value drops to 359 MiB, practically to the level
of the full JDK. A class-data archive allows the class state to be mapped in
as a memory-mapped file instead of being placed on the heap — exactly
what the image without an archive lacks.</p><p>That makes CDS, in this setup, a<strong>memory tool, not a
speed tool</strong>:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>+28 MB image → −11 MiB memory → ±0 ms startup time</code></pre></div><p>Whether that is worth it depends on what is scarcer. On a server with 4 GB of RAM
and plenty of disk: yes. On the reference server, which is short of neither
memory nor disk: not necessary — and that is why the shipped image is the one
without CDS.</p><h3 id="what-did-not-change">What did not change</h3><table><thead><tr><th/><th>full JDK</th><th>jlink</th></tr></thead><tbody><tr><td>Threads in operation</td><td>31</td><td>31</td></tr><tr><td>HTTP response</td><td>200</td><td>200</td></tr><tr><td>Hardening score</td><td>1.1</td><td>1.1</td></tr><tr><td>Lines in the systemd unit</td><td>unchanged</td><td>unchanged</td></tr></tbody></table><p>The application notices nothing of running on a different JVM. That is
the real finding of this chapter:<strong>an image of 16 modules behaves
like a JDK of 68</strong> — except for 290 ms that nobody can explain and 12 MiB
that CDS brings back.</p><h2 id="what-changes-about-the-release-model">What changes about the release model</h2><p>Moving the runtime into the release shifts a
responsibility. That has two sides, and the unpleasant one comes first.</p><h3 id="a-java-update-is-now-a-release">A Java update is now a release</h3><p>Before:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sdk install java 26.0.3-tem</span></span><span class="line"><span class="cl">sdk default java 26.0.3-tem</span></span><span class="line"><span class="cl">chown -R root:vaadinapp /var/lib/vaadinapp/.sdkman</span></span><span class="line"><span class="cl">systemctl restart vaadinapp</span></span></code></pre></div></div><p>Four lines, no build, no artifact, a few minutes.</p><p>After: build a new image, pack it into a new release, ship it, repoint the
symlink. The procedure from Chapter 8, in full.</p><p><strong>That is more effort, and it should not be talked down.</strong> Anyone who applies a
monthly JDK security update will from now on run it through the release pipeline.
Without build automation that carries it, this quickly becomes an update that
does not happen — and an outdated Java in the release is worse than a
current one on the machine.</p><p>One qualification puts the objection into perspective for this server:<strong>the
automatic updating from Part 1, Step 21 never covered the JDK anyway.</strong> It
maintains Debian packages; the JDK came via SDKMAN. So the step was already
manual before. What changes is not that it becomes manual, but that it becomes<strong>visible</strong>: as a release with a date that can be kept available or taken back,
instead of a symlink that someone repointed at some point.</p><h3 id="in-return-the-java-version-travels-with-the-rollback">In return, the Java version travels with the rollback</h3><p>That is the other side, and in a series that dedicated Part 5 to the rollback
it is the more important one.</p><p>Until now a rollback was incomplete: it took back the application and left
the runtime standing. Anyone who observes a problem after a JDK update and
goes back to the previous release goes back to the previous<strong>application</strong> — on
the new Java. The problem stays, and the cause is now harder to
find, because the one change that was just taken back was not the one
that mattered.</p><p>With its own runtime the rollback is complete.<code>ln -sfn</code> to the previous
release takes back application and JVM together.<strong>The state that ran can be
restored as a whole</strong> — and not just two-thirds of it.</p><h3 id="the-attack-surface-provable">The attack surface, provable</h3><p>52 modules no longer sit on the server. What that is worth shows when
a vulnerability report comes in.</p><p>With a full JDK, the question &ldquo;Does this affect us?&rdquo; is a judgment call: the
module is there, maybe it is not loaded, you cannot be sure. With a
jlink image it is a query:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">/opt/vaadinapp/current/runtime/bin/java --list-modules<span class="p">|</span> grep<span class="s1">'^jdk.scripting'</span></span></span><span class="line"><span class="cl"><span class="c1"># (no output)</span></span></span></code></pre></div></div><p><strong>No match means: not included.</strong> That is not an argument you have to
make, but one you can prove — and for a VEX document that justifies the
status<code>not_affected</code>, the difference is exactly the one between a
claim and evidence.</p><p>The same holds for the case Chapter 2 described: anyone who can execute code
on the server finds no compiler there, no scripting engine, and
no tools for attaching to other JVM processes. That prevents no
break-in. It shortens what is possible afterwards.</p><h3 id="the-gap-that-remains">The gap that remains</h3><p>Two things do not get better through this approach, and they belong
here, because otherwise the chapter would look too good.</p><p><strong>The bill of materials does not know the runtime.</strong> Chapter 7 named it:<code>sbom.json</code>
describes 129 Java and 118 JavaScript components; the 16 modules of the image
are not in it. Of all things, the component for which responsibility
was just taken is missing from the document that is supposed to prove
responsibility.</p><p><strong>The module list is a maintained file.</strong> It comes from an analysis plus
three entries from failure signatures (Chapter 5). If a dependency arrives that
needs another module, nobody notices automatically — in the fortunate case
at startup, in the unfortunate one with the first user who invokes the affected
function.</p><p>Whoever adopts jlink takes on both as an ongoing task. That is the price
for the runtime becoming part of the release, and it is only
appropriate once the gain is actually needed.</p><h2 id="the-comparison-across-the-series--and-where-the-application-ends">The comparison across the series — and where the application ends</h2><p>Six parts, five delivery formats, one server. What each format expects from the
target system — and what it brings along.</p><h3 id="the-overview">The overview</h3><table><thead><tr><th>Part</th><th>Form</th><th>Release on the server</th><th>required there</th></tr></thead><tbody><tr><td>1–2</td><td>WAR to external Jetty</td><td>21 MB</td><td>JDK (309 MB)<strong>and</strong> Jetty (44 MB distribution)</td></tr><tr><td>3</td><td>embedded Jetty, unpacked</td><td>29 MB</td><td>JDK</td></tr><tr><td>4</td><td>thin distribution</td><td>29 MB</td><td>JDK</td></tr><tr><td>4</td><td>fat JAR</td><td>28 MB</td><td>JDK</td></tr><tr><td>6</td><td>thin distribution + own runtime</td><td>94 MB</td><td><strong>nothing</strong></td></tr></tbody></table><p>All five variants sit simultaneously in<code>/opt/vaadinapp/releases/</code> and were
measured in a single pass — that is why the numbers are comparable. The archive
sizes from Part 5 (26.68 MB for the thin distribution, 25.81 MB for the fat JAR)
come from an older state of the code and are not.</p><p>The right-hand column is the thread that runs through the series, and read from
top to bottom it is a single movement: what the machine provided at first, the
release provides at the end.</p><p>The column next to it shows what that costs — and across four
parts the answer is: almost nothing. The release stays between 21 and 29 MB while
the prerequisites on the server shrink from two third-party components to one.
The Jetty from Part 1 moves into the application in Part 3 and shows up there as
the 8 MB difference between 21 and 29 MB — a good deal against a
44 MB distribution that someone has to install and maintain.</p><p>Only the last step costs:<strong>from 29 to 94 MB.</strong> The difference is the
runtime, and it is more than twice the size of the application
that it runs.</p><h3 id="which-form-for-what">Which form for what</h3><p><strong>WAR to an external Jetty</strong> — when operations provides an application server
and several applications run on it. Then Jetty is shared
infrastructure, and shipping it per application would be wasteful. The price
is depending on two states that someone else maintains; Part 2 showed
how tight the coupling between Vaadin version and servlet version
actually is.</p><p><strong>Fat JAR</strong> — the default for the normal case. One archive, one checksum,
no directory of 128 files. Part 5 measured that it starts neither faster
nor slower and needs around 48 MiB more memory; that is tolerable.
The price is in Part 4, Chapter 5: the merge is not a copy operation,
and whoever gets it wrong builds an archive that starts and does not work.</p><p><strong>Thin distribution</strong> — when you want to see what is being shipped. 127 archives,
each with a name and a version, each individually replaceable. For debugging and for
everything related to license and vulnerability checking, this is the
more pleasant form.</p><p><strong>Own runtime</strong> — when the Java version is to belong to the release. Three cases
justify the effort: a target system on which no Java can or may be
installed; an operation in which the rollback must be complete; and
an environment in which the attack surface must be provable rather than estimated.</p><p>For everything else, an installed JDK plus fat JAR is the simpler solution,
and in operations, simpler is a value in itself.</p><h3 id="where-does-the-application-end">Where does the application end?</h3><p>The series has been moving this boundary for six parts, and at no point was it
clear where it belongs.</p><p>Part 1 would have answered: the application ends at the WAR. Everything before
it is its business, everything after it is operations. Part 3 pulled Jetty
inside, because the coupling between Vaadin and servlet version was too tight
to run it across a system boundary. Part 6 pulled the JVM inside, because a
rollback that leaves the runtime standing is not a complete rollback.</p><p>What remains is not a technical answer but one about
responsibilities:</p><div class="pull-quote"><p><strong>The application ends where the next decision is made by someone else
— and where that is fine.</strong> Not every shared responsibility is a
problem. It becomes one when two places decide about the same behavior
and neither of them checks the whole.</p></div><p>This series pushed the boundary far, because the reference server is one
that a single person operates. Where one team provides operations and another the
application, it lies elsewhere — and there the WAR from Part 1 would be the right
answer, not the outdated one.</p><h3 id="the-step-that-does-not-come">The step that does not come</h3><p>The container.</p><p>It is the obvious next thought, and it solves the same problem — an
image that brings everything along, on a host that provides nothing. Two things
speak for treating it separately.</p><p><strong>It does not replace the question, it relocates it.</strong> A container image with a
full JDK carries the same 309 MB, just one layer further down. The work from
Chapters 4 and 5 is due there just the same, and it pays off there<strong>more
strongly</strong> than here: an image that is multiplied into every registry, onto
every node, and into every layer profits from 79% less footprint more than a
disk on which 309 MB sit once. Chapter 2 ranked that as this series&rsquo; weakest
argument — in the container it becomes the strongest.</p><p><strong>It brings a second system boundary with it.</strong> Network, file system, process
isolation, registry, signing — each of these is a topic of its own, and the
systemd hardening from Part 1, which this series holds at 1.1, would have to be
answered anew there. Squeezing that into a closing chapter would mean treating
it badly.</p><p>What carries over from this series into a container is therefore not the
result but the method:<strong>a module list that cannot be derived;
a startup attempt that proves nothing; and a release that describes its own
state completely.</strong> Whoever has that can put it into a container image.
Whoever does not puts a problem inside and a layer on top.</p>
]]></content:encoded><category>Java</category><category>Vaadin</category><media:content url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink-hero.png" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink-hero.png"/><enclosure url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-6-self-contained-vaadin-with-jlink-hero.png" type="image/jpeg" length="0"/></item></channel></rss>