<?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>SDKMAN on Sven Ruppert</title><link>https://svenruppert.com/tags/sdkman/</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/sdkman/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/sdkman/</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></channel></rss>