<?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>Reverse Proxy on Sven Ruppert</title><link>https://svenruppert.com/tags/reverse-proxy/</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/reverse-proxy/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/reverse-proxy/</link></image><lastBuildDate>Thu, 24 Sep 2026 10:00:00 +0200</lastBuildDate><item><title>Vaadin Deployment on Hetzner — Part 2: Publicly Reachable with Caddy and HTTPS</title><link>https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-2-publicly-reachable-with-caddy-and-https/</link><pubDate>Thu, 24 Sep 2026 10:00:00 +0200</pubDate><author>sven.ruppert@gmail.com (Sven Ruppert)</author><dc:creator>Sven Ruppert</dc:creator><guid isPermaLink="true">https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-2-publicly-reachable-with-caddy-and-https/</guid><description>Putting a Vaadin app on the public internet with Caddy: automatic HTTPS, forwarded headers, server push, and operating the service without downtime.</description><content:encoded>&lt;![CDATA[<p>Continuation of Part 1 of the series<em>Vaadin – Deployment on Hetzner</em>. From a local service to a publicly reachable application: Caddy, HTTPS, forwarded headers, Push — and day-to-day operations with updates and rollback. All commands, outputs, and measurements come from an actual run on a Debian 13 server.</p><h2 id="starting-point">Starting Point</h2><p>This part picks up where Part 1 left off: a Vaadin application that runs as
its own system service, hardened — and that nobody can reach.</p><h3 id="the-state-everything-builds-on">The state everything builds on</h3><table><thead><tr><th>Building block</th><th>State at the end of Part 1</th></tr></thead><tbody><tr><td>Temurin JDK 26</td><td>via SDKMAN in the service account&rsquo;s home, no system-wide Java</td></tr><tr><td>Jetty 12.1.12</td><td>servlet container,<code>ee11</code>, bound to<code>127.0.0.1:8080</code></td></tr><tr><td><code>ROOT.war</code></td><td>21 MB, production build, served at<code>/</code></td></tr><tr><td><code>vaadinapp.service</code></td><td>starts at boot, logs to journald, hardening score 1.1</td></tr><tr><td>Reachability</td><td>local only</td></tr></tbody></table><p>The cross-check on the server:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -o /dev/null -w<span class="s1">'%{http_code}\n'</span> http://127.0.0.1:8080/</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>200</code></pre></div><p>From your own workstation, on the other hand: nothing. The service is bound to
the loopback address, and the firewall from Part 0 only admits 22, 80, and 443
inbound anyway.</p><p>If you are joining at this point, you should at least have skimmed Part 1 —
above all the chapters on the bind address and the unit file. The decisions
made there are assumed here and exploited in several places.</p><h3 id="the-code-state-for-this-part">The code state for this part</h3><p>As in Part 1: the state this part describes lives on the tag<code>teil-02</code>.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/svenruppert/Blog-Vaadin-Deployment-on-Hetzner</span></span><span class="line"><span class="cl"><span class="nb">cd</span> Blog-Vaadin-Deployment-on-Hetzner</span></span><span class="line"><span class="cl">git checkout teil-02</span></span></code></pre></div></div><p>A single Maven module, built to<code>target/ROOT.war</code>. From Part 3 on, the project
is split into modules; the paths here apply to the single-module state.</p><h3 id="what-this-part-adds">What this part adds</h3><figure><img src="/images/2026/09/vaadin-deployment-on-hetzner-part-2-publicly-reachable-with-caddy-and-https-teil02-anfragepfad.png" alt="Caddy as the public endpoint in front of the service from Part 1" loading="lazy" decoding="async"/><p><em>Figure 1: Caddy goes in front; the service from Part 1 remains unchanged.</em></p><p>Between a running service and a usable application lie more steps than a
single line of proxy configuration suggests.</p><p><strong><a href="https://8g8.eu/gl8qju">Caddy</a></strong> becomes the only service visible from the outside. It accepts the
requests, terminates TLS, obtains and renews the certificate itself, and passes
everything else through to<code>127.0.0.1:8080</code>.</p><p><strong>The application has to learn that it is behind a proxy.</strong> A request that
comes in through Caddy appears, from Jetty&rsquo;s point of view, to originate from<code>127.0.0.1</code>, is unencrypted, and carries a different hostname than the one the
browser called. Without correction, Vaadin sets session cookies without<code>Secure</code> and builds redirects to<code>http://</code>. Chapter 4 deals with that.</p><p><strong>Vaadin Push</strong> keeps a persistent connection open. It survives a
reverse proxy only if the proxy explicitly passes it through.</p><p><strong>After that, operations begin:</strong> configuration outside the archive, secrets
and file permissions, a repeatable procedure for the next release, a
controlled rollback — and the question of what happens to the running
sessions during all of this.</p><h3 id="the-path-from-the-outside">The path from the outside</h3><p>At the end of this part, the application is reachable under its own domain
name over HTTPS, set up, and updatable. The service itself stays exactly where
Part 1 parked it: bound to<code>127.0.0.1</code>, hardened, unchanged. What is added
goes in front — not inside.</p><h2 id="installing-caddy">Installing Caddy</h2><h3 id="the-role-of-the-reverse-proxy">The role of the reverse proxy</h3><p>Jetty listens on the loopback address and cannot be reached from the outside.
Caddy sits exactly in between: it accepts the requests from the internet,
terminates TLS, and forwards them unencrypted to<code>127.0.0.1:8080</code>.</p><p>This separates two jobs that would otherwise have to be handled by the same
process. The application server takes care of the application; everything
related to public reachability — certificates, redirects, compression,
access logging — sits in front of it.</p><h3 id="installation">Installation</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt-get install debian-keyring debian-archive-keyring apt-transport-https curl</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">curl -1sLf<span class="s1">'https://dl.cloudsmith.io/public/caddy/stable/gpg.key'</span><span class="se">\</span></span></span><span class="line"><span class="cl"><span class="p">|</span> sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">curl -1sLf<span class="s1">'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt'</span><span class="se">\</span></span></span><span class="line"><span class="cl"><span class="p">|</span> sudo tee /etc/apt/sources.list.d/caddy-stable.list</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl">sudo apt-get update</span></span><span class="line"><span class="cl">sudo apt-get install caddy</span></span></code></pre></div></div><h3 id="the-difference-from-part-1-chapter-10">The difference from Part 1, Chapter 10</h3><p>What is remarkable is what does<strong>not</strong> have to be done afterwards:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">systemctl is-enabled caddy<span class="c1"># enabled</span></span></span><span class="line"><span class="cl">systemctl is-active caddy<span class="c1"># active</span></span></span><span class="line"><span class="cl">getent passwd caddy<span class="c1"># caddy:/var/lib/caddy:/usr/sbin/nologin</span></span></span></code></pre></div></div><p>The service is already running, with its own system account and a finished
unit definition. For Jetty, Chapters 9 and 10 required the same work by hand.</p><p>That is no coincidence but the difference between a distribution and an
application runtime. Caddy is a finished program with a clear operating model;
Jetty is a runtime whose operating model only emerges once an application is
placed inside it.</p><h3 id="prepackaged-does-not-mean-hardened">Prepackaged does not mean hardened</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">systemd-analyze security caddy.service</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>→ Overall exposure level for caddy.service: 8.8 EXPOSED 🙁</code></pre></div><p>The same treatment as in Part 1, Chapter 11 — but explicitly<strong>not</strong> the same
file:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /etc/systemd/system/caddy.service.d</span></span><span class="line"><span class="cl">sudo nano /etc/systemd/system/caddy.service.d/10-hardening.conf</span></span></code></pre></div></div><p>The differences are the actual lesson:</p><table><thead><tr><th>Directive</th><th><code>vaadinapp</code></th><th><code>caddy</code></th><th>Reason</th></tr></thead><tbody><tr><td><code>CapabilityBoundingSet</code></td><td>empty</td><td><code>CAP_NET_BIND_SERVICE</code></td><td>Caddy binds 80 and 443</td></tr><tr><td><code>IPAddressDeny=any</code></td><td>yes</td><td><strong>no</strong></td><td>certificate acquisition needs the network</td></tr><tr><td><code>ReadWritePaths</code></td><td><code>/var/lib/vaadinapp/…</code></td><td><code>/var/lib/caddy /var/log/caddy</code></td><td>certificate storage, access log</td></tr><tr><td><code>MemoryDenyWriteExecute</code></td><td><strong>no</strong></td><td><strong>yes</strong></td><td>Go program, no runtime code generation</td></tr></tbody></table><p>Two assumptions can be verified in practice.</p><p><strong>Does Caddy get by without<code>CAP_NET_ADMIN</code>?</strong> The Debian package grants two
capabilities. Keeping only the one for binding privileged ports is enough:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">grep -E<span class="s1">'^Cap(Prm|Eff|Bnd)'</span> /proc/<span class="k">$(</span>systemctl show -p MainPID --value caddy<span class="k">)</span>/status</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>CapPrm: 0000000000000400
CapEff: 0000000000000400
CapBnd: 0000000000000400</code></pre></div><p><code>0x400</code> is exactly bit 10 —<code>CAP_NET_BIND_SERVICE</code>, nothing else. HTTPS,
HTTP/2, HTTP/3, and certificate acquisition keep working unchanged.</p><p><strong>Does Caddy tolerate<code>MemoryDenyWriteExecute</code>?</strong> Yes. The directive that
dismantles every JVM in Part 1, Chapter 11 is unproblematic here.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>→ Overall exposure level for caddy.service: 1.5 OK 🙂</code></pre></div><p>After this chapter, one thing holds: Caddy is the only process reachable from
the internet. What it forwards is decided in Chapter 3.</p><h2 id="domain-and-https">Domain and HTTPS</h2><h3 id="dns">DNS</h3><p>The prerequisite is an A record pointing to the server&rsquo;s public
address:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig +short A demo.svenruppert.com</span></span><span class="line"><span class="cl"><span class="c1"># 95.216.150.155</span></span></span></code></pre></div></div><p>Anyone who additionally publishes an AAAA record has to handle IPv6 end to end —
firewall, Caddy, and application. An AAAA record in front of a firewall
configured only for IPv4 produces partial outages that affect only some
visitors and are therefore hard to track down.</p><h3 id="caddyfile">Caddyfile</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo nano /etc/caddy/Caddyfile</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>demo.svenruppert.com {
reverse_proxy 127.0.0.1:8080
}</code></pre></div><p>Two lines of substance. No certificate path, no ACME client, no
redirect rule, no<code>listen 443 ssl</code>.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo systemctl reload caddy</span></span></code></pre></div></div><h3 id="automatic-certificates">Automatic certificates</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo journalctl -u caddy -n<span class="m">30</span></span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>"msg":"using ACME account"
"msg":"trying to solve challenge","identifier":"demo.svenruppert.com","challenge_type":"http-01"
"msg":"authorization finalized","authz_status":"valid"
"msg":"certificate obtained successfully","identifier":"demo.svenruppert.com"</code></pre></div><p>Five seconds from configuration to a valid certificate. The challenge runs
over port 80 — which is why it stands open in the firewall even though the
application is meant to be reachable exclusively over HTTPS.</p><h3 id="http-to-https">HTTP to HTTPS</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -sI http://demo.svenruppert.com/<span class="p">|</span> head -3</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>HTTP/1.1 308 Permanent Redirect
Location: https://demo.svenruppert.com/
Server: Caddy</code></pre></div><p>Nobody configured this redirect. Caddy sets it up as soon as a
hostname appears in the Caddyfile.</p><h3 id="tls">TLS</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span><span class="p">|</span> openssl s_client -servername demo.svenruppert.com<span class="se">\</span></span></span><span class="line"><span class="cl"> -connect demo.svenruppert.com:443 2&gt;/dev/null<span class="se">\</span></span></span><span class="line"><span class="cl"><span class="p">|</span> openssl x509 -noout -subject -issuer -dates</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>subject=CN=demo.svenruppert.com
issuer=C=US, O=Let's Encrypt, CN=YE1
notBefore=Sep 4 08:56:45 2026 GMT
notAfter=Dec 3 08:56:44 2026 GMT</code></pre></div><p>Caddy handles renewal itself, without a cron entry and without additional
software.</p><h4 id="caa-who-may-issue-at-all">CAA: who may issue at all</h4><p>The A record determines where the domain lives. It says nothing about<strong>who</strong>
may issue a certificate for it — by default, the answer is: any
publicly trusted certificate authority.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>svenruppert.com. CAA 0 issue "letsencrypt.org"</code></pre></div><p>The check is binding for all publicly trusted authorities. The record
is resolved from the full name upwards; an entry on the
registrable domain covers all subdomains as well.</p><p>A record that is too narrow locks you out — and the mistake only shows up at
the next renewal, possibly months later. That is why setting it should be
followed by an actual reissuance, not just a syntax check.</p><div class="pull-quote"><h3 id="sidebar-iodef--the-record-that-does-almost-nothing">Sidebar:<code>iodef</code> — the record that does almost nothing</h3><p>Besides<code>issue</code>, the standard knows a third property:<code>iodef</code> names
an address to which a certificate authority is supposed to send a report
when it has refused an issuance because of the policy. The idea is
compelling — you would learn about an attempt.</p><p>In practice, this does not hold up. The standard phrases the sending as
optional, and<strong>Let&rsquo;s Encrypt does not implement it</strong>. Anyone who sets the
record and relies on it has an alarm system without a bell.</p><p>On top of that: the record is public in DNS and gets harvested by address
collectors. A private email address ends up permanently on spam lists.</p><p>Recommendation: leave it out and set only<code>issue</code>.</p></div><h3 id="a-production-grade-caddyfile">A production-grade Caddyfile</h3><p>The minimum works — for production operation, quite a bit is missing:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>{
admin off
}
demo.svenruppert.com {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "SAMEORIGIN"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
-Server
}
reverse_proxy 127.0.0.1:8080 {
transport http {
read_timeout 0
write_timeout 0
}
}
log {
output file /var/log/caddy/demo.svenruppert.com.log {
roll_size 10MiB
roll_keep 10
}
format json
}
}</code></pre></div><p><code>-Server</code> removes the<code>Server: Jetty(12.1.12)</code> identifier from the response. No
client needs the exact version, but every scanner does — Chapter 4 shows
that these scanners actually do come by.</p><p><code>admin off</code> disables Caddy&rsquo;s administration interface on<code>127.0.0.1:2019</code>.
Through it, the entire configuration could be changed without authentication.
The price:<code>systemctl reload caddy</code> no longer works afterwards; changes
require a<code>restart</code>.</p><p>The timeouts in the<code>transport</code> block are the only item in this file that can
actively break Vaadin Push — Chapter 5 explains why.</p><h3 id="the-pitfall-when-validating">The pitfall when validating</h3><p>The obvious sequence is: validate first, then reload.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo caddy validate --config /etc/caddy/Caddyfile<span class="c1"># "Valid configuration"</span></span></span><span class="line"><span class="cl">sudo systemctl reload caddy<span class="c1"># fails</span></span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>open /var/log/caddy/demo.svenruppert.com.log: permission denied</code></pre></div><p>The message is misleading, because the directory does belong to<code>caddy</code>. A
look at the file explains it:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ls -l /var/log/caddy/</span></span><span class="line"><span class="cl"><span class="c1"># -rw------- 1 root root 0 demo.svenruppert.com.log</span></span></span></code></pre></div></div><p><strong><code>caddy validate</code> instantiates the log writers and creates the file in the
process</strong> — under<code>sudo</code>, that means as<code>root</code> with<code>0600</code>. The service
running as<code>caddy</code> cannot subsequently open its own log file.</p><p>The fix: delete the file, reload. Or run the validation as the service account:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo -u caddy caddy validate --config /etc/caddy/Caddyfile</span></span></code></pre></div></div><p>What is remarkable about the behavior: Caddy does<strong>not</strong> adopt a
configuration it cannot load — the old one kept running, the site stayed
reachable. Only a<code>restart</code> took the service down. With a reverse proxy, when
in doubt:<code>reload</code>, not<code>restart</code>.</p><h2 id="forwarded-headers-and-scheme-behind-caddy">Forwarded Headers and Scheme Behind Caddy</h2><p>The application is reachable, the certificate valid. Yet at this point the
installation is still wrong — and the mistake is invisible.</p><h3 id="what-the-application-sees">What the application sees</h3><p>Caddy accepts an HTTPS connection from a client somewhere on the internet
and establishes an<strong>unencrypted</strong> connection from the loopback address to
Jetty. From the application&rsquo;s point of view, every request therefore looks
like this:</p><ul><li>Scheme:<code>http</code>, not<code>https</code></li><li>Client address:<code>127.0.0.1</code></li><li>Port: 8080</li></ul><p>Three consequences follow, and none of them announces itself as an error:</p><p><strong>Absolute URLs are generated incorrectly.</strong> Wherever the application builds a
complete address — redirects, return addresses, links in emails — it contains<code>http://</code> and the internal port.</p><p><strong>The access log is worthless.</strong> Every request apparently comes from<code>127.0.0.1</code>.</p><p><strong>Session cookies lose their protection.</strong> A cookie with the<code>Secure</code> flag
is only transmitted over encrypted connections. If the application believes
the connection is unencrypted, it does not set the flag.</p><h3 id="making-the-error-visible">Making the error visible</h3><p>An access log in Jetty makes the state verifiable:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> /opt/vaadinapp</span></span><span class="line"><span class="cl">sudo java -jar /opt/jetty/start.jar --add-modules<span class="o">=</span>requestlog</span></span><span class="line"><span class="cl">sudo nano start.d/requestlog.ini</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>jetty.requestlog.filePath=/var/lib/vaadinapp/logs/yyyy_mm_dd.request.log</code></pre></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo systemctl restart vaadinapp</span></span><span class="line"><span class="cl">curl -s https://demo.svenruppert.com/ &gt; /dev/null</span></span><span class="line"><span class="cl">sudo tail -1 /var/lib/vaadinapp/logs/*.request.log</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>127.0.0.1 - - [04/Sep/2026:09:56:48 +0000] "GET / HTTP/1.1" 200 198 "" "curl/8.7.1"</code></pre></div><p><strong>Every</strong> request — from the office next door or from Australia — appears in
the log as<code>127.0.0.1</code>.</p><h3 id="the-fix">The fix</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> /opt/vaadinapp</span></span><span class="line"><span class="cl">sudo java -jar /opt/jetty/start.jar --add-modules<span class="o">=</span>forwarded</span></span><span class="line"><span class="cl">sudo systemctl restart vaadinapp</span></span></code></pre></div></div><div class="pull-quote"><p><strong>The module name has changed.</strong> In Jetty 12.1 it is called<code>forwarded</code>.<code>http-forwarded</code> still exists but is explicitly marked as
deprecated — anyone following a guide written for Jetty 12.0 will pick the
old name.</p></div><p>The same request afterwards:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>80.187.114.255 - - [04/Sep/2026:09:57:11 +0000] "GET / HTTP/1.1" 200 198 "" "curl/8.7.1"</code></pre></div><p><strong>On the Caddy side there is nothing to do.</strong> Caddy sets<code>X-Forwarded-For</code>,<code>X-Forwarded-Proto</code>, and<code>X-Forwarded-Host</code>
with<code>reverse_proxy</code> on its own.<strong>No</strong> additional directive is needed —
all that is required is that Jetty actually evaluates these headers.</p><h3 id="the-proof-that-counts">The proof that counts</h3><p>The effect can be read directly from the real application&rsquo;s response:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -sI https://demo.svenruppert.com/<span class="p">|</span> grep -i set-cookie</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>set-cookie: JSESSIONID=node0pcg76yqw1hqd1btn7lkvwrmj66.node0; Path=/; Secure</code></pre></div><p>The<strong><code>Secure</code></strong> flag is set. So Jetty knows that the connection is
TLS-terminated — and that is achieved exclusively by this module. Without it,
the flag would be missing, and the session cookie would also travel over
unencrypted connections.</p><h3 id="the-trust-boundary">The trust boundary</h3><p>Forwarded headers are client-supplied claims. Whoever evaluates them believes
what the request says — a client could assert any address.</p><p>Here that is harmless,<strong>because Jetty listens exclusively on the loopback
address</strong> (Part 1, Chapter 8). Forged headers can only be sent by someone who
is already on the server; everything from outside inevitably passes through
Caddy, and Caddy sets the values itself.</p><p>Bind address and forwarded-header evaluation are therefore connected: the one
decision justifies the other. If you let Jetty listen on<code>0.0.0.0</code> while also
evaluating forwarded headers, you would have a log that anyone is allowed to write to.</p><h3 id="an-incidental-finding-that-was-not-planned">An incidental finding that was not planned</h3><p>A few minutes after the certificate was issued, external addresses appeared in
the log:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>64.227.32.66 - - [...] "GET /config.json HTTP/1.1" 404 ... "l9scan/2.0 (+https://leakix.net)"
64.227.32.66 - - [...] "GET /telescope/requests HTTP/1.1" 404 ...
64.227.32.66 - - [...] "GET /info.php HTTP/1.1" 404 ...</code></pre></div><p>The certificate was issued at 09:55:17; the first external request arrived at
09:56:28 —<strong>one minute and eleven seconds later.</strong> After that, it came thick
and fast:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>09:56:56 GET /console/
09:56:57 GET /server
09:56:58 GET /server-status
09:57:00 GET /about
09:57:01 GET /login.action
09:57:02 GET /___proxy_subdomain_whm/login
09:57:02 GET /v2/_catalog</code></pre></div><p>A cross-section of known vulnerabilities:<code>/login.action</code> looks for vulnerable
Confluence,<code>/v2/_catalog</code> for an open container registry,<code>/telescope/requests</code> for a Laravel diagnostic tool left in production,<code>/server-status</code> for an open Apache status page. In total,<strong>sixteen
different external addresses</strong> within a few minutes.</p><p>How did they know? From the<strong>Certificate Transparency logs.</strong> Every
issued TLS certificate is published in a public, machine-readable registry —
a security feature that makes misissuance detectable.
It has a side effect: anyone looking for new hostnames simply reads along. A
server is publicly known from the second its certificate is issued, not only
once someone passes the address around.</p><p>Together with the numbers from Part 0, Chapter 7 — the first login attempt
twenty-three seconds after boot — this adds up to a clear picture.</p><p><strong>And without this chapter, none of it would have been seen.</strong> Before the
module was activated, every log line read<code>127.0.0.1</code>. The accesses were there
the whole time, just invisible. A log that cannot distinguish the office next
door from a scanner network is not a log.</p><h2 id="vaadin-push-behind-caddy">Vaadin Push Behind Caddy</h2><p>Vaadin keeps the UI state on the server. Changes not triggered by a
user action — an incoming record, a progress update, a
notification — therefore need a path from the server to the browser. That path
is provided by Vaadin Push.</p><h3 id="websockets">WebSockets</h3><p>Technically, a push connection begins as an ordinary HTTP request asking for a
protocol upgrade:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>GET /VAADIN/push?v-r=push HTTP/1.1
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13</code></pre></div><p>If the server answers with<code>101 Switching Protocols</code>, the connection stays open
and both sides send whenever they want. Exactly this upgrade is what an
intermediate reverse proxy has to pass through.</p><h3 id="on-the-jetty-side">On the Jetty side</h3><p>The required module was activated in Part 1, Chapter 7:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>ee11-websocket-jakarta</code></pre></div><p>If it is missing, the upgrade fails, and Vaadin falls back to a technique that
polls the server at regular intervals. The application then still works — just
slower and with more load. That is the unpleasant kind of error: everything
looks fine.</p><h3 id="proxying--and-what-caddy-needs-for-it">Proxying — and what Caddy needs for it</h3><p>Nothing.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>reverse_proxy 127.0.0.1:8080</code></pre></div><p>Caddy passes upgrade connections through transparently with<code>reverse_proxy</code>
on its own. There is no<code>proxy_http_version</code> setting, no<code>Upgrade</code> and<code>Connection</code> headers to set manually, no separate<code>location</code>
block for the push path.</p><p>That is worth mentioning because the widespread guides for other reverse
proxies contain exactly those lines — and because the official Vaadin
documentation on reverse proxies covers only Apache HTTPD and nginx. Caddy
does not appear there. Anyone copying from there will search for an equivalent
that does not exist, because it is not needed.</p><h3 id="upgrade-connections-and-timeouts">Upgrade connections and timeouts</h3><p>The only item in the Caddyfile that can actively break Push is time limits:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>reverse_proxy 127.0.0.1:8080 {
transport http {
read_timeout 0
write_timeout 0
}
}</code></pre></div><p>A push connection is normally<strong>silent</strong>. It exists without data
flowing — until something happens. A read timeout of 30 seconds terminates it
after 30 seconds of quiet.</p><p>The error does not show up as an error: the application re-establishes the
connection, and everything appears normal. But updates occasionally fail to
arrive, and short-lived connections pile up in the log.<code>0</code> means no limit.</p><h3 id="proof">Proof</h3><p>The push endpoint responds:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -o /dev/null -w<span class="s1">'%{http_code}\n'</span><span class="se">\</span></span></span><span class="line"><span class="cl"><span class="s1">'https://demo.svenruppert.com/VAADIN/push?v-r=push'</span></span></span><span class="line"><span class="cl"><span class="c1"># 200</span></span></span></code></pre></div></div><p>More reliable is the observation in the browser: in the developer tools, the
network requests show a connection to<code>/VAADIN/push</code> with status<code>101</code> and type<code>websocket</code> that stays open.</p><p>If it does not stay open, or if instead of<code>101</code> an ordinary<code>200</code> response appears and repeats every few seconds, the upgrade has
failed — then either the Jetty module is missing or a time limit is kicking in.</p><p><strong>What this means for the following parts:</strong> The push connection is the most visible expression of what Chapter 10 deals with:
the server holds the state of this UI. If the service is restarted, not
only does the connection drop — the state behind it is gone.</p><h2 id="configuration-outside-the-war">Configuration Outside the WAR</h2><p>The artifact from Part 1, Chapter 4 is the same on every server. Whatever differs —
storage location, ports, addresses of neighboring systems — must therefore not
live inside it.</p><p>The reason is not aesthetics. An artifact that contains environment values has
to be rebuilt for every environment. Then the thing that was tested is not
the thing that is shipped.</p><h3 id="the-resolution-chain">The resolution chain</h3><p>The sample application resolves configuration values in three stages:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="n">String</span><span class="w"/><span class="nf">resolve</span><span class="p">(</span><span class="n">String</span><span class="w"/><span class="n">systemProperty</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">envVariable</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fallback</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fromSystem</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">System</span><span class="p">.</span><span class="na">getProperty</span><span class="p">(</span><span class="n">systemProperty</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">fromSystem</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="o">!</span><span class="n">fromSystem</span><span class="p">.</span><span class="na">isBlank</span><span class="p">())</span><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fromSystem</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">String</span><span class="w"/><span class="n">fromEnv</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">System</span><span class="p">.</span><span class="na">getenv</span><span class="p">(</span><span class="n">envVariable</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">fromEnv</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="o">!</span><span class="n">fromEnv</span><span class="p">.</span><span class="na">isBlank</span><span class="p">())</span><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fromEnv</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">return</span><span class="w"/><span class="n">fallback</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>A system property beats an environment variable; an environment variable beats
the default.</p><p>The order is deliberate: the system property is the path for the development
machine — it can be passed individually at startup without changing the
environment. The environment variable is the path for the server, because it
can be fed from a file maintained by someone other than the person performing
the start.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>app.host → APP_HOST → built-in default
app.port → APP_PORT → built-in default</code></pre></div><h3 id="environment">Environment</h3><p>On the server, these values come from a file that the unit definition
reads:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">ini</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">EnvironmentFile</span><span class="o">=</span><span class="s">-/etc/vaadinapp/environment</span></span></span></code></pre></div></div><p>The leading minus sign means: the file<strong>may be missing.</strong> Without it, the
application starts with its defaults instead of aborting with an error.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo nano /etc/vaadinapp/environment</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>APP_HOST=127.0.0.1
APP_PORT=8080</code></pre></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo chown root:vaadinapp /etc/vaadinapp/environment</span></span><span class="line"><span class="cl">sudo chmod<span class="m">640</span> /etc/vaadinapp/environment</span></span></code></pre></div></div><p><code>root:vaadinapp</code> with<code>640</code>: the service may read, not write. After a
change, restarting the service is all it takes; the artifact remains untouched.</p><h3 id="separating-artifact-and-operational-data">Separating artifact and operational data</h3><p>That puts three things in three places, each with a different lifecycle:</p><table><thead><tr><th/><th>Location</th><th>changes</th></tr></thead><tbody><tr><td>Artifact</td><td><code>/opt/vaadinapp/webapps/ROOT.war</code></td><td>with every release</td></tr><tr><td>Configuration</td><td><code>/etc/vaadinapp/environment</code></td><td>when the environment changes</td></tr><tr><td>Data</td><td><code>/var/lib/vaadinapp/data</code></td><td>continuously during operation</td></tr></tbody></table><p>A release swaps out only the first row. Chapter 8 turns this into a
procedure.</p><h3 id="one-value-breaks-the-pattern">One value breaks the pattern</h3><p>The data location is not set through the environment file but in
the unit definition:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>-Dapp.storage.dir=/var/lib/vaadinapp/data</code></pre></div><p>The reason is an irregularity in the application: this one key is evaluated
only as a system property, not additionally as an environment variable. A
line<code>APP_STORAGE_DIR=…</code> in the environment file would have no effect.</p><p>For operations, this makes no difference — the value simply lives in the
unit definition instead of the environment file, and both belong to<code>root</code>.
It is still worth mentioning, because an exception without a substantive
reason costs the reader more than it explains: anyone who has understood the
chain from the first section expects it everywhere.</p><p>Part 1, Chapter 11 showed what happens when this value is not set<strong>at all</strong>.</p><p><strong>What does not belong here:</strong> credentials. An environment variable appears in<code>/proc/&lt;PID&gt;/environ</code> and is
thus readable by anyone working as the same user. Chapter 7 covers
the difference.</p><h2 id="secrets-and-file-permissions">Secrets and File Permissions</h2><p>Configuration and credentials look similar and are therefore often treated the
same. They are not: a misplaced port is an operational error;
a misplaced access token is a security incident.</p><h3 id="two-principles">Two principles</h3><p><strong>No secrets in the WAR.</strong> The first principle is the simplest. An artifact travels across
development machines, version control, staging areas, and backups. Whatever is
inside it exists in many places at once.</p><p><strong>No secrets as process arguments.</strong> The second principle is better known than it is followed. It can be
demonstrated in two lines:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># NOT like this:</span></span></span><span class="line"><span class="cl">java -Dapi.token<span class="o">=</span>s3cr3t -jar app.jar</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># From any other account on the same system:</span></span></span><span class="line"><span class="cl">ps aux<span class="p">|</span> grep api.token</span></span></code></pre></div></div><p>The process list is readable by every user on the system. That is not a
misconfiguration but normal behavior — process arguments are public.</p><h3 id="three-levels">Three levels</h3><p>This yields an ordering that leads from bad to good:</p><table><thead><tr><th/><th>Method</th><th>Who can get at it</th></tr></thead><tbody><tr><td>❌</td><td><code>-Dtoken=…</code> as an argument</td><td><strong>every</strong> user, via<code>ps</code></td></tr><tr><td>⚠️</td><td><code>EnvironmentFile</code></td><td>anyone working as the same user, via<code>/proc/&lt;PID&gt;/environ</code></td></tr><tr><td>✅</td><td><code>LoadCredentialEncrypted</code></td><td>only the service itself</td></tr></tbody></table><p>The middle level is where many stop — the file has<code>0640</code>, belongs to<code>root</code>, and that feels safe. The detour via<code>/proc</code> remains open nonetheless, and combined with<code>ptrace</code> access to the process memory all the more so. Part 0, Chapter 8 therefore
set the corresponding kernel parameter to the strict value.</p><h3 id="the-third-level">The third level</h3><p>systemd can store credentials encrypted and deliver them to the service without
them appearing as an environment variable or a readable file:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">printf</span><span class="s1">'%s'</span><span class="s2">"</span><span class="nv">$TOKEN</span><span class="s2">"</span><span class="p">|</span> sudo systemd-creds encrypt --name<span class="o">=</span>api-token -<span class="se">\</span></span></span><span class="line"><span class="cl"> /etc/vaadinapp/api-token.cred</span></span><span class="line"><span class="cl">sudo chmod<span class="m">600</span> /etc/vaadinapp/api-token.cred</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">ini</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/vaadinapp.service.d/20-credentials.conf</span></span></span><span class="line"><span class="cl"><span class="k">[Service]</span></span></span><span class="line"><span class="cl"><span class="na">LoadCredentialEncrypted</span><span class="o">=</span><span class="s">api-token:/etc/vaadinapp/api-token.cred</span></span></span></code></pre></div></div><p>The service finds the value at<code>$CREDENTIALS_DIRECTORY/api-token</code>.</p><h3 id="proof-1">Proof</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ps aux<span class="p">|</span> grep -c<span class="s2">"</span><span class="nv">$TOKEN</span><span class="s2">"</span><span class="c1"># 0</span></span></span><span class="line"><span class="cl">tr<span class="s1">'\0'</span><span class="s1">'\n'</span> &lt; /proc/<span class="k">$(</span>systemctl show -p MainPID --value vaadinapp<span class="k">)</span>/environ<span class="se">\</span></span></span><span class="line"><span class="cl"><span class="p">|</span> grep -c<span class="s2">"</span><span class="nv">$TOKEN</span><span class="s2">"</span><span class="c1"># 0</span></span></span></code></pre></div></div><p>And the delivery itself:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ls -l /run/credentials/vaadinapp.service/</span></span><span class="line"><span class="cl">findmnt -no FSTYPE,OPTIONS /run/credentials/vaadinapp.service</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>-r--r-----+ 1 root root 28 api-token
tmpfs ro,nosuid,nodev,noexec,nosymfollow,size=1024k,mode=700,noswap</code></pre></div><p>A<strong>read-only tmpfs with<code>noexec</code>,<code>mode=700</code>, and<code>noswap</code>.</strong> The
value never touches the disk in plaintext and does not end up in the
swap file. A foreign account gets<code>Permission denied</code>.</p><h3 id="ownership-and-permissions">Ownership and permissions</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>/etc/vaadinapp/api-token.cred root:root 0600
/etc/vaadinapp/environment root:vaadinapp 0640
/opt/vaadinapp/webapps/ROOT.war root:vaadinapp 0640
/var/lib/vaadinapp/data vaadinapp:vaadinapp 0700</code></pre></div><p>Exactly one path belongs to the service, and it is the one it has to write to.</p><h3 id="a-caveat-that-belongs-here">A caveat that belongs here</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>Credential secret file '/var/lib/systemd/credential.secret'
is not located on encrypted media, using anyway.</code></pre></div><p>The virtual machine used here has no TPM. Without one, the
decryption key sits as a host key on the same disk. The protection
works against accidental disclosure, against plaintext backups, and against
the two paths from the proof above —<strong>not</strong> against someone who already has
root privileges on the running system.</p><p>That needs saying; otherwise the method promises more than it delivers.</p><h3 id="what-is-not-covered">What is not covered</h3><p>Rotating credentials during operation. A change here means:
encrypt a new file, restart the service — with the downtime from
Chapter 8. Automated rotation is a topic of its own.</p><p>And one final sentence that is missing too often: whoever logs an access
token has it in the journal. All the care in delivery is then wasted.</p><h2 id="manual-update">Manual Update</h2><h3 id="keeping-releases-side-by-side">Keeping releases side by side</h3><p>A deployment that overwrites the old file knows no way back. That is why every
release gets its own directory:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>/opt/vaadinapp/
├── releases/
│ ├── 2026-09-04-1200/ROOT.war
│ └── 2026-09-07-1442/ROOT.war
└── webapps/
└── ROOT.war ← the active version</code></pre></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo install -d -m<span class="m">750</span> -o root -g vaadinapp /opt/vaadinapp/releases</span></span></code></pre></div></div><p>The archive belongs to<code>root:vaadinapp</code>. The service may read, not write —
anyone who takes over the application can alter neither the active artifact
nor its predecessors.</p><h3 id="staging-the-new-war">Staging the new WAR</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mvn -Pproduction clean package</span></span><span class="line"><span class="cl">scp target/ROOT.war sven@demo.svenruppert.com:/home/sven/</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">STAMP</span><span class="o">=</span><span class="k">$(</span>date +%Y-%m-%d-%H%M<span class="k">)</span></span></span><span class="line"><span class="cl">sudo install -d -m<span class="m">750</span> -o root -g vaadinapp /opt/vaadinapp/releases/<span class="nv">$STAMP</span></span></span><span class="line"><span class="cl">sudo install -m<span class="m">640</span> -o root -g vaadinapp /home/sven/ROOT.war<span class="se">\</span></span></span><span class="line"><span class="cl"> /opt/vaadinapp/releases/<span class="nv">$STAMP</span>/</span></span><span class="line"><span class="cl">rm /home/sven/ROOT.war</span></span></code></pre></div></div><p>Up to this point, nothing has happened. The running application is untouched;
the new release is merely staged.</p><h3 id="stop-the-service-swap-the-deployment-start-the-service">Stop the service, swap the deployment, start the service</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo systemctl stop vaadinapp</span></span><span class="line"><span class="cl">sudo install -m<span class="m">640</span> -o root -g vaadinapp<span class="se">\</span></span></span><span class="line"><span class="cl"> /opt/vaadinapp/releases/<span class="nv">$STAMP</span>/ROOT.war /opt/vaadinapp/webapps/ROOT.war</span></span><span class="line"><span class="cl">sudo systemctl start vaadinapp</span></span></code></pre></div></div><p>Three commands.<code>install</code> instead of<code>cp</code>, for the same reason as in Part 1, Chapter 9.</p><h3 id="why-no-swap-during-operation">Why no swap during operation</h3><p>Jetty&rsquo;s monitoring of the<code>webapps</code> directory could detect a changed file on
its own and redeploy it. That is disabled here, and for good
reason: a swap during operation means that two versions of the
application briefly exist at the same time — with the same data store.</p><p>The explicit stop is more honest. It costs downtime, but the state is
unambiguous at every point in time.</p><h3 id="what-it-costs">What it costs</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo systemctl restart vaadinapp</span></span><span class="line"><span class="cl"><span class="c1"># Measure the time until the first successful response</span></span></span></code></pre></div></div><table><thead><tr><th/><th>Value</th></tr></thead><tbody><tr><td>Artifact size</td><td>21 MB</td></tr><tr><td>Downtime until the first response</td><td><strong>around 5.7 seconds</strong></td></tr><tr><td>of which Jetty startup, per the log</td><td>4.1 seconds</td></tr><tr><td>Memory footprint at idle</td><td>461 MiB of 1,536 MiB</td></tr><tr><td>Threads</td><td>33</td></tr></tbody></table><p>The downtime consists of three components: starting the JVM,
unpacking the archive to<code>java.io.tmpdir</code>, and starting the application
itself — for this application including opening its data store.</p><p>The values come from the run with the slimmed-down archive from Part 1, Chapter 4,
measured on 2026-09-07 at release<code>2026-09-07-1627</code>. The
comparison with the previous state is instructive, because it refutes a
widespread expectation:</p><table><thead><tr><th/><th>28 MB, 126 libraries</th><th>21 MB, 86 libraries</th></tr></thead><tbody><tr><td>Downtime</td><td>6.3 s</td><td>5.7 s</td></tr><tr><td>Jetty startup</td><td>4.2 s</td><td>4.1 s</td></tr><tr><td>Memory at idle</td><td>445 MiB</td><td>461 MiB</td></tr></tbody></table><p>A quarter less archive shortens the startup by a good half second — that
is the share contributed by unpacking and by scanning the libraries for
annotations.<strong>Memory consumption, however, does not drop</strong>; within
measurement noise it is even slightly higher. That is no contradiction: what
an archive weighs says little about how many classes the application actually
loads at runtime. What was removed were libraries that were never going to be
loaded anyway. Shrinking a WAR buys transfer time, disk space, and
clarity — not RAM.</p><p>For comparison: a minimal web application without its own data store was
reachable on the same server after around 2 seconds. The difference is not Jetty; it is the
application.</p><p>These numbers are the baseline for the parts that follow. It will be
interesting to see whether embedded Jetty (Part 3) and the custom runtime from
Part 6 measurably shorten the startup.</p><p><strong>Four steps that could be automated:</strong> build, transfer, swap, restart. Exactly these four steps are what a
deployment pipeline replaces — nothing more. Anyone who has run them by hand once
knows afterwards where an automation can fail.</p><h2 id="rollback">Rollback</h2><h3 id="keeping-the-previous-artifact">Keeping the previous artifact</h3><p>The way back is only possible because Chapter 8 kept the old releases.
Without the archive there would be no rollback, only a fresh build from a
hopefully matching state of version control — under time pressure, in
the middle of an incident.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ls -1 /opt/vaadinapp/releases/</span></span></code></pre></div></div><p>How many releases to keep is a question of disk space. At
21 MB per artifact, ten releases are 210 MB — with 75 GB of
disk space, not a serious consideration.</p><h3 id="a-controlled-rollback">A controlled rollback</h3><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo systemctl stop vaadinapp</span></span><span class="line"><span class="cl">sudo install -m<span class="m">640</span> -o root -g vaadinapp<span class="se">\</span></span></span><span class="line"><span class="cl"> /opt/vaadinapp/releases/2026-09-04-1200/ROOT.war /opt/vaadinapp/webapps/ROOT.war</span></span><span class="line"><span class="cl">sudo systemctl start vaadinapp</span></span></code></pre></div></div><p>The same procedure as in Chapter 8, just with an older timestamp.</p><h3 id="a-rollback-costs-exactly-as-much-as-a-deployment">A rollback costs exactly as much as a deployment</h3><p>That is the most important statement in this chapter, and it surprises many.</p><p>Both operations consist of the same three commands and the same downtime —
around six seconds. There is<strong>no technical penalty for backing out.</strong></p><p>Anyone hesitating because a rollback is supposedly costly hesitates without
reason. What is costly is not the way back but the decision: recognizing that
something is wrong, and making the call.</p><p>Which release is currently active can be determined at any time:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sha256sum /opt/vaadinapp/webapps/ROOT.war</span></span><span class="line"><span class="cl">sha256sum /opt/vaadinapp/releases/*/ROOT.war</span></span></code></pre></div></div><h3 id="the-limit-the-data-does-not-come-back">The limit: the data does not come back</h3><p>Here the symmetry ends, and this is the point that deployment guides like to
leave out.</p><p>The rollback swaps<strong>only the artifact.</strong> The data under<code>/var/lib/vaadinapp/data</code> stays as it is — it is not part of the release
and survives every swap.</p><p>As long as a new application version only reads and writes what the old one
also understood, this is unproblematic. As soon as it changes the storage
format — an additional field, a changed structure, a migration at startup —
this no longer holds: the old version then encounters data it does not expect.</p><p>Two operational rules follow:</p><p><strong>Before a release that changes the data, a backup belongs in
place.</strong> A rollback is then artifact<strong>and</strong> data.</p><p><strong>Whoever runs a migration at startup should know whether it is reversible.</strong>
If it is not, there is no rollback anymore, only rolling forward —
and that requires a working new version.</p><p>For the sample application with its embedded store, this means concretely: a
rollback across a format change requires the backup of the
data directory. Without it, the way back stays open exactly as long as the
storage format remains unchanged.</p><h2 id="vaadin-sessions-and-restarts">Vaadin Sessions and Restarts</h2><p>Chapter 8 put a number on the downtime: around six seconds. For a
stateless application, that would be the whole story. For a Vaadin application,
it is the smaller part.</p><h3 id="server-side-ui-state">Server-side UI state</h3><p>Vaadin keeps the state of the UI on the server. Which view is open,
what a form contains, which row of a table is selected —
all of that lives in the session, not in the browser. The browser renders and
reports events back.</p><p>That is the property that defines Vaadin: application logic in Java, without
separate state management in the frontend. It has a price, and the price comes
due at every restart.</p><h3 id="loss-of-running-sessions">Loss of running sessions</h3><p>A restart terminates the process. With it, the entire session state
disappears.</p><p>That is<strong>not a flaw of this deployment.</strong> It follows directly from the
architecture: whoever keeps state in the server loses it with the server. A
stateless REST backend loses nothing on a restart, because it has nothing to
lose.</p><p>The difference deserves naming, because it shapes expectations. The
sample application with login and roles makes it tangible: what is affected is
not a counter but a logged-in session in the middle of work.</p><h3 id="impact-on-users">Impact on users</h3><p>Concretely, it looks like this: the page reloads, the login is gone, unsaved
input is lost. With Push active (Chapter 5), the open connection additionally
drops, and the browser tries to re-establish it.</p><p>The stored data is unaffected — whatever was saved lives under<code>/var/lib/vaadinapp/data</code> and survives. What is lost is exclusively what had
not yet been saved.</p><h3 id="session-persistence--and-why-it-is-not-used-here">Session persistence — and why it is not used here</h3><p>Jetty can serialize sessions and carry them across a restart. The
prerequisite is that the<strong>entire</strong> session content is serializable.</p><p>With Vaadin, that is a well-known sore spot. The session content is the
component tree of the UI along with all registered listeners. A single lambda
capturing a non-serializable reference is enough to make the
serialization fail — and not during development, but at
restart in production.</p><p>It is doable, but it demands discipline across the entire application code and
a test that covers this case. For this series, it is therefore named and
not done.</p><h3 id="maintenance-windows">Maintenance windows</h3><p>The pragmatic answer with one server and manual deployment: restarts are
scheduled, not endured.</p><p>Remarkably, this decision was already made in Part 0. Whoever enabled<code>unattended-upgrades</code> with automatic reboot there has fixed their
maintenance window — namely 3:30 in the morning. Whoever did not enable it
has unpatched kernels. Both are defensible; staying undecided
is not.</p><h3 id="where-this-series-draws-a-line">Where this series draws a line</h3><p>Two instances behind Caddy, updated alternately, would defuse the
problem: while one restarts, the other serves. That is an
established technique and particularly attractive for a Vaadin application,
because it limits the session loss to half the users — provided the proxy
keeps sessions on the same instance.</p><p>However, it breaks the constraints from Part 1, Chapter 2: one server, no
orchestration. That is why it stands here as a boundary and not as a guide.</p><h3 id="why-this-chapter-is-in-the-title">Why this chapter is in the title</h3><p>Alongside Push from Chapter 5, this is the second place where a property<strong>of Vaadin</strong> shapes the deployment — and the only one that changes the
operating procedure itself. Everything else — Jetty, systemd, Caddy,
certificates, hardening — would apply equally to any Java web application.</p><h2 id="result">Result</h2><p>A Vaadin application is reachable under its own domain name over HTTPS.
Four building blocks across two parts, each set up individually, each
individually verifiable.</p><h3 id="the-last-step-setting-up-the-application">The last step: setting up the application</h3><p>Deployed does not yet mean usable. The sample application ships with login and
roles and therefore needs an initial administrator account — no
deployment can deliver that, because it would contain a password.</p><p>On first start, the application instead writes a one-time token into its
data directory:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo cat /var/lib/vaadinapp/data/jcustos/bootstrap.token</span></span></code></pre></div></div><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>token=XXXX-XXXX-XXXX-XXXX-XXXX
createdAt=2026-09-07T12:44:10Z</code></pre></div><p>In the browser, the application then walks through the setup: token, username,
password. After that, the file is used up.</p><p><strong>The procedure is more remarkable than it looks.</strong> The token exists
exclusively on the server, readable only by the service account and by<code>root</code>.
Whoever finds the application on the net before it is set up cannot complete
the setup — they would need access to the file system for that. Given the
scanners from Chapter 4, which come by within a minute, that is no
theoretical advantage: a setup page without such protection would be an
open door for exactly the span between certificate and first login.</p><p>This protection is a property of the application, not of the deployment. It
belongs here because it makes the difference between a reachable and an
operational installation — and because everyone following this guide will
look for it at this point.</p><h3 id="what-is-running-now">What is running now</h3><table><thead><tr><th>Building block</th><th>State</th></tr></thead><tbody><tr><td>Caddy</td><td>public endpoint, TLS, automatic certificates, hardened (1.5)</td></tr><tr><td>Jetty 12.1.12</td><td>servlet container,<code>ee11</code>, only on<code>127.0.0.1:8080</code></td></tr><tr><td><code>ROOT.war</code></td><td>21 MB, production build, served at<code>/</code></td></tr><tr><td>systemd</td><td>service starts at boot, confined (1.1), logs to journald</td></tr><tr><td>Temurin JDK 26</td><td>via SDKMAN, no system-wide Java</td></tr></tbody></table><h3 id="the-numbers">The numbers</h3><table><thead><tr><th/><th/></tr></thead><tbody><tr><td>Downtime for restart, deployment, and rollback</td><td>around 5.7 seconds</td></tr><tr><td>of which Jetty startup</td><td>4.1 seconds</td></tr><tr><td>Memory footprint at idle</td><td>461 MiB of 1,536 MiB</td></tr><tr><td>Threads</td><td>33</td></tr><tr><td>Steps per release</td><td>4</td></tr><tr><td>Hardening score<code>vaadinapp</code></td><td>from 9.2 to 1.1</td></tr><tr><td>Hardening score<code>caddy</code></td><td>from 8.8 to 1.5</td></tr></tbody></table><p>These values are the yardstick that Parts 3 through 5 will have to measure up
against.</p><div class="pull-quote"><p><strong>Measurement status:</strong> All values in this part were taken on 2026-09-07 on the reference server, release<code>2026-09-07-1627</code>. From Part 3 on, the same server runs the
embedded setup. For the comparison in Part 3, the state described here was
measured once more in full immediately before the switch —
with three restarts instead of one deployment, which explains the small
deviations.</p></div><h3 id="what-became-visible-along-the-way">What became visible along the way</h3><p>Four observations carry beyond this part.</p><p><strong>Individual measures only work in combination, even across parts.</strong> The
bind address from Part 1, Chapter 8 justifies the forwarded-header evaluation
from Chapter 4 — without the one, the other would be either ineffective or
dangerous. The kernel parameter from Part 0, Chapter 8 closes the gap that
Chapter 7 leaves open. None of these measures stands on its own, and none of
them sits where you would look for it first.</p><p><strong>Two services, two sets of directives.</strong> Caddy and the application run on the
same server and still needed different hardening: what breaks the JVM does not
bother a program written in Go, and vice versa. Copying a template from one to
the other would have damaged both — one rendered inoperative, the other left
needlessly exposed.</p><p><strong>The certificate is a starting gun.</strong> One minute passed between the issuance
and the first scanner probing the new name. A domain name in the
certificate log is public the moment it comes into existence. Anyone who only
starts thinking about hardening afterwards is thinking too late — which is why
the service stood hardened at the end of Part 1 before anyone could reach it
at all.</p><p><strong>A rollback costs nothing.</strong> The same three commands, the same downtime.
What is expensive is not the way back but the decision — and the data, which
does not come along.</p><h3 id="what-this-setup-does-not-deliver">What this setup does not deliver</h3><p>For honesty&rsquo;s sake, because the following parts pick up exactly here:</p><p><strong>There is downtime.</strong> Around six seconds per release, and all running
sessions are lost in the process.</p><p><strong>Four things have to be maintained separately:</strong> operating system, JDK, Jetty, and
application. Each has its own update cycle, and only one of them is
in the application developer&rsquo;s hands.</p><p><strong>The artifact is incomplete.</strong> A WAR does not run on its own; it needs a
container of the right version with the right modules. What works on the
development machine can fail on a differently configured server.</p><p>Part 3 starts at exactly this last point.</p><h2 id="outlook-on-part-3">Outlook on Part 3</h2><h3 id="the-boundary-shifts">The boundary shifts</h3><p>Part 1, Chapter 1 named the through line: in Parts 1 and 2, the
application consists exclusively of the WAR. Jetty, the Java runtime, and the server are
environment — present, maintained, updated independently.</p><p>Part 3 shifts this boundary for the first time.<strong>Jetty becomes a library
of the application.</strong></p><h3 id="what-changes-as-a-result">What changes as a result</h3><p>Instead of an installed servlet container into which an archive is placed,
there is an application with its own<code>main</code> method. It creates the server,
configures the connector, mounts the Vaadin servlet, and starts.</p><p>Concretely, several things from Part 1 disappear:</p><table><thead><tr><th>from Part 1</th><th>in Part 3</th></tr></thead><tbody><tr><td><code>/opt/jetty</code> as an installation</td><td>a Maven dependency</td></tr><tr><td><code>JETTY_HOME</code> and<code>JETTY_BASE</code></td><td>gone</td></tr><tr><td><code>--add-modules=…</code></td><td>dependencies in<code>pom.xml</code></td></tr><tr><td><code>start.d/http.ini</code></td><td>Java code in the startup sequence</td></tr><tr><td><code>webapps/ROOT.war</code></td><td>an executable artifact</td></tr></tbody></table><h3 id="what-stays">What stays</h3><p>More remarkable than the differences is how little changes. Caddy remains
unchanged — it talks to<code>127.0.0.1:8080</code> and does not know what runs behind
it. The unit definition from Part 1, Chapter 10 essentially changes its<code>ExecStart</code> line. The directory layout, the service account, the hardening, the
configuration, and the credentials stay as they are.</p><p>That is no coincidence but the result of the separation that Part 0 and
Chapters 9 through 11 of Part 1 built up: the operational frame is independent of how
the application starts internally.</p><h3 id="what-is-gained-and-what-is-given-up">What is gained and what is given up</h3><p><strong>Gained</strong> is an artifact that is more complete. The Jetty version becomes a
decision of the application project and moves into version control — a
server with a misconfigured container can no longer damage the
application.</p><p><strong>Given up</strong> is independence. From Part 3 on, a security update for Jetty is
an application release: rebuild, redeliver, restart. In Part 1,
it was a symlink and a restart, without touching the application.</p><p>Which of the two splits is the right one depends on who maintains the server
and how often releases ship. Part 6 puts all five variants side by side at
the end — with the numbers from Chapter 11 as the starting point.</p>
]]></content:encoded><category>Java</category><category>Vaadin</category><media:content url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-2-publicly-reachable-with-caddy-and-https-hero.png" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-2-publicly-reachable-with-caddy-and-https-hero.png"/><enclosure url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-2-publicly-reachable-with-caddy-and-https-hero.png" type="image/jpeg" length="0"/></item></channel></rss>