<?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>Fail2ban on Sven Ruppert</title><link>https://svenruppert.com/tags/fail2ban/</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/fail2ban/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/fail2ban/</link></image><lastBuildDate>Thu, 24 Sep 2026 10:00:00 +0200</lastBuildDate><item><title>Vaadin Deployment on Hetzner — Part 0: Preparing a Debian Server</title><link>https://svenruppert.com/posts/vaadin-deployment-on-hetzner-part-0-preparing-a-debian-server/</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-0-preparing-a-debian-server/</guid><description>Hardening a fresh Hetzner Debian server for Java workloads: SSH, firewall, unattended upgrades — and why the first foreign login attempt arrived after 23 seconds.</description><content:encoded>&lt;![CDATA[<p>Foundation article of the series<em>Vaadin – Deployment on Hetzner</em>. It produces the server on which the following parts put a Vaadin application into operation. All commands and outputs come from an actual run on a Debian 13 server.</p><h2 id="goal-of-this-foundation-article">Goal of This Foundation Article</h2><p>This series describes how a Vaadin application goes into operation on a server
of your own — without Docker, without Kubernetes, without a CI/CD pipeline. Six
parts show the same application in five different deployment forms, from the
classic WAR on an installed Jetty to the self-sufficient distribution that
brings its own Java runtime.</p><p>This part comes before all the others. It does not describe a single step that
has anything to do with Vaadin. It produces the server on which everything else
takes place.</p><h3 id="why-this-deserves-an-article-of-its-own">Why This Deserves an Article of Its Own</h3><p>A freshly provisioned server is not a secure server. It accepts password logins
for<code>root</code>, has no firewall, knows only a single user, and nowhere separates
program, configuration, and data. All of this can be fixed in just under an
hour — and all of it is regularly skipped, because it is not the task you
actually set out to do.</p><p>The urgency can be quantified. On the server on which this series was written,
the first foreign login attempt appeared in the log<strong>twenty-three seconds
after boot</strong>. At that point no service was published, no domain pointed to the
address, and nobody but the operator knew it. Chapter 7 shows the numbers in
detail.</p><p>Hardening is therefore not a precaution for the case that somebody comes by.
Somebody will come by. The only open question is what they will find.</p><h3 id="what-this-part-produces">What This Part Produces</h3><p>At the end stands a Debian server that is kept up to date, on which nobody can
log in as<code>root</code> or with a password anymore, whose firewall lets only three
inbound ports through, and whose directories separate program, configuration,
and mutable data from one another. Chapter 11 lists the achieved state as a
verifiable checklist.</p><p>The last point looks like tidiness and is not. The separation is the
precondition for effectively confining the application service in Part 1 —
Chapter 10 comes back to this.</p><h3 id="who-can-skip-this-part">Who Can Skip This Part</h3><p>Anyone who already runs a server the way just described can continue directly
with Part 1. Chapter 11 summarizes the achieved state in a list that can be
used as a cross-check.</p><p>Everyone else should start here. Part 1 assumes this state and only refers
back to it.</p><h3 id="a-word-on-version-numbers">A Word on Version Numbers</h3><p>The versions mentioned are the state at the time this series was written. They
age — Debian, the JDK, Jetty, and Caddy appear in new releases while this text
is still being read. This changes nothing about the described procedure; the
commands stay the same, only the numbers in them shift. Anyone following along
checks the current releases and adjusts accordingly.</p><h3 id="the-demo-application-for-this-series">The Demo Application for This Series</h3><p>This part does not touch the repository — it produces the server and nothing
else. The application that the following parts deploy is open, and every part
has its own code state:</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></code></pre></div></div><p>The README maps the tags to the parts. The first checkout becomes relevant in
Part 1.</p><h2 id="prerequisites">Prerequisites</h2><p>This article assumes a server that already exists and is reachable.
Specifically:</p><h3 id="a-server-exists">A Server Exists</h3><p>A virtual or physical server with root access. How it came into being — via
the Hetzner console, at another provider, as a virtual machine in the basement
— does not matter for anything that follows.</p><p>The series title mentions Hetzner because the series was written there. In
terms of content, nothing from here on is provider-specific. No cloud
consoles, no provider APIs, and no vendor-specific firewalls appear. Everything
runs in the operating system, and that applies equally at every provider.</p><h3 id="debian-13">Debian 13</h3><p>The operating system is Debian 13 (&ldquo;trixie&rdquo;). The series was developed and
verified on it.</p><p>On Ubuntu LTS the procedure works largely identically — package names and
paths match,<code>systemd</code> behaves the same. Two spots differ: Ubuntu already
ships<code>ufw</code>, and the defaults of the SSH configuration are different. Where
this becomes relevant, a note points it out.</p><h3 id="ssh-login-works">SSH Login Works</h3><p>Logging in as<code>root</code> via SSH already works — whether by key or by password
does not matter at this point. Chapter 3 starts at the first connection.</p><h3 id="public-ip-address">Public IP Address</h3><p>The server has a publicly reachable IPv4 address. It will be needed in Part 1,
when a DNS name is to point to it and a TLS certificate is to be issued.</p><p>An IPv6 address is useful but not required. Anyone who uses it must handle it
consistently: firewall, reverse proxy, and DNS record must match. A published
AAAA record in front of a firewall configured only for IPv4 produces partial
outages that are hard to diagnose because they affect only some of the
visitors.</p><h3 id="what-this-article-does-not-cover">What This Article Does Not Cover</h3><p>To be clear about the boundaries:</p><ul><li><strong>How the server comes into being.</strong> Ordering process, console interfaces,
and plan selection do not appear.</li><li><strong>Backups.</strong> A server without backups is incompletely operated, but the
topic has requirements of its own and does not belong in an article about
basic setup.</li><li><strong>Monitoring and alerting.</strong> Just as important, just as much a topic of its
own.</li><li><strong>Multiple applications on one server.</strong> The procedure transfers to that,
but is shown here with exactly one application.</li><li><strong>High availability.</strong> This is about one server, not two. Where that has
noticeable consequences — with restarts, for example — it is stated
explicitly.</li></ul><h3 id="prerequisites-on-the-workstation">Prerequisites on the Workstation</h3><p>On your own machine you need: an SSH client, an SSH key pair, and<code>dig</code> or a
comparable tool for DNS queries. All three are present on Linux and macOS; on
Windows, the OpenSSH client provides the first two.</p><p>Anyone who does not have a key pair yet creates one once:</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-keygen -t ed25519 -C<span class="s2">"description"</span></span></span></code></pre></div></div><p>The key type<code>ed25519</code> is the right choice today: short keys, fast
verification, no parameter decisions. RSA still works, but brings no
advantage.</p><h2 id="first-login">First Login</h2><h3 id="verifying-the-host-key-fingerprint-before-connecting">Verifying the Host Key Fingerprint Before Connecting</h3><p>On the first<code>ssh</code> to an unknown server, this question appears:</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>The authenticity of host '198.51.100.10' can't be established.
ED25519 key fingerprint is SHA256:1IuzQgLqT1igW12PXwm21tZVst6deL77O4URLtCVvcQ.
Are you sure you want to continue connecting (yes/no/[fingerprint])?</code></pre></div><p>Anyone who types &ldquo;yes&rdquo; here answers a question they have not verified. The
fingerprint is stored in<code>~/.ssh/known_hosts</code> and treated as trustworthy from
then on — even if it comes from an attacker who has inserted himself between
the two sides.</p><p>That is exactly what the question is for. It can be answered if you know the
fingerprint beforehand. And it can be obtained without logging in:</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-keyscan 198.51.100.10 &gt; /tmp/hostkeys.txt</span></span><span class="line"><span class="cl">ssh-keygen -lf /tmp/hostkeys.txt</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>256 SHA256:1IuzQgLqT1igW12PXwm21tZVst6deL77O4URLtCVvcQ 198.51.100.10 (ED25519)
3072 SHA256:qRRKI3aDfWddsRbY6BcvWtpns06AbUI62GycM3PLp+c 198.51.100.10 (RSA)
256 SHA256:FzRV3J01kuU9sxKy76duO4C+kBiiQmC4PdSyWZCpyB4 198.51.100.10 (ECDSA)</code></pre></div><p>The objection is obvious: whoever can intercept the connection can also forge
the answer to<code>ssh-keyscan</code>. That is true. The comparison is therefore only
worth something if the fingerprint comes from a<strong>second, independent source</strong>
— from the provider&rsquo;s console, from a serial connection, or from the
provisioning log. The two-source comparison is the actual step;<code>ssh-keyscan</code>
only supplies one side of it.</p><p>Three keys are the factory state. Chapter 6 reduces them to one — then there
is exactly one fingerprint to verify instead of three, of which it is unclear
which one currently applies.</p><h3 id="offered-authentication-methods">Offered Authentication Methods</h3><p>Even before the first login, you can determine how the server accepts logins
in the first place:</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 -o<span class="nv">PreferredAuthentications</span><span class="o">=</span>none root@198.51.100.10</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>root@198.51.100.10: Permission denied (publickey,password).</code></pre></div><p>The parenthesis is the answer: the server accepts keys<strong>and</strong> passwords.
That is the usual factory state — and the state that Chapter 6 eliminates.
After the hardening, only<code>publickey</code> remains there, and that is exactly how
success can be read off.</p><h3 id="surveying-the-initial-state">Surveying the Initial State</h3><figure><img src="/images/2026/09/vaadin-deployment-on-hetzner-part-0-preparing-a-debian-server-teil00-ausgangszustand.png" alt="Factory state: one service, no firewall, no separation of roles" loading="lazy" decoding="async"/><p><em>Figure 1: Captured on the reference server before the first change was made.</em></p><p>Before the first change, an inventory is worthwhile. It costs a minute and
later answers the question of what the system came with and what has been
added.</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"># What is reachable from outside?</span></span></span><span class="line"><span class="cl">ss -tlnp</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Are there users besides root?</span></span></span><span class="line"><span class="cl">awk -F:<span class="s1">'$3&gt;=1000 &amp;&amp; $7!~/nologin|false/ {print $1}'</span> /etc/passwd</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Does a firewall exist?</span></span></span><span class="line"><span class="cl"><span class="nb">command</span> -v ufw<span class="o">||</span><span class="nb">echo</span><span class="s2">"ufw not installed"</span></span></span><span class="line"><span class="cl">nft list ruleset</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># How many updates are pending?</span></span></span><span class="line"><span class="cl">apt-get update<span class="o">&amp;&amp;</span> apt-get -s upgrade<span class="p">|</span> grep -c<span class="s1">'^Inst'</span></span></span></code></pre></div></div><p>On the server on which this series was written, it looked like this:</p><ul><li>exclusively<code>sshd</code> on<code>0.0.0.0:22</code> and<code>[::]:22</code></li><li>no user with a UID of 1000 or above — only<code>root</code></li><li>no firewall, no<code>nftables</code> ruleset</li><li>four pending package updates</li></ul><p>That is a typical factory state: exactly one service is running, and it
accepts password logins for the most powerful user of the system.</p><div class="pull-quote"><h3 id="if-the-server-comes-with-a-root-password">If the Server Comes With a Root Password</h3><p>Some providers deliver a server without a deposited SSH key and send a root
password instead. On the first login, the system then immediately forces a
change:</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>You are required to change your password immediately (administrator enforced).
WARNING: Your password has expired.
You must change your password now and log in again!
New password:
Retype new password:
passwd: password updated successfully
Connection to 198.51.100.10 closed.</code></pre></div><p>Two properties of this dialog regularly come as a surprise.<strong>First</strong>, the
connection is closed after the change — that is not an error; you log in
again with the new password. In scripts, however, it looks like an abort.<strong>Second</strong>, the new password is asked for only once and displayed nowhere.
Anyone who has not thought beforehand about where it belongs has a problem.</p><p>This route also opens a time window in which<code>root</code> is reachable from the
Internet by password. How short this window should be is shown by the
numbers in Chapter 7. Where there is a choice, an SSH key deposited at
creation time is the better option — then this section disappears entirely.</p></div><p><strong>Result.</strong> The fingerprint is verified, the initial state is noted, the connection stands.
From here on, things change — starting with the system&rsquo;s package state.</p><h2 id="updating-the-system">Updating the System</h2><h3 id="apt-update-and-apt-upgrade"><code>apt update</code> and<code>apt upgrade</code></h3><p>The delivery state of an image is rarely the current package state. Weeks lie
between the creation of the image and the provisioning of 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">apt-get update</span></span><span class="line"><span class="cl">apt-get upgrade</span></span></code></pre></div></div><p>On the reference server it was four packages — the<code>python3.13</code> stack. Not
much, but among them was a security update.</p><h3 id="kernel-update-and-reboot">Kernel Update and Reboot</h3><p>Here lurks the first pitfall, and it is treacherous because nothing points to
it.</p><p><code>apt upgrade</code> installs a new kernel<strong>but does not activate it</strong>. The server
keeps running on the old one — including the vulnerabilities the update was
supposed to close. In<code>apt</code>, everything looks clean.</p><p>It can be checked with two commands:</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">uname -r<span class="c1"># running kernel</span></span></span><span class="line"><span class="cl">ls -1 /boot/vmlinuz-*<span class="p">|</span> sort -V<span class="p">|</span> tail -1<span class="c1"># installed kernel</span></span></span></code></pre></div></div><p>On the reference server:</p><table><thead><tr><th/><th/></tr></thead><tbody><tr><td>running</td><td><code>6.12.88+deb13-cloud-amd64</code></td></tr><tr><td>installed</td><td><code>6.12.107+deb13-cloud-amd64</code></td></tr></tbody></table><p>Debian additionally reports the need via a 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="o">[</span> -f /var/run/reboot-required<span class="o">]</span><span class="o">&amp;&amp;</span><span class="nb">echo</span><span class="s2">"reboot required"</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">systemctl reboot</span></span></code></pre></div></div><p>After booting:</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">uname -r<span class="c1"># 6.12.107+deb13-cloud-amd64</span></span></span><span class="line"><span class="cl">uptime -p<span class="c1"># up 0 minutes</span></span></span></code></pre></div></div><h3 id="automatic-security-updates">Automatic Security Updates</h3><p>A server that is updated only when it is set up is open to attack after a few
months. Debian ships<code>unattended-upgrades</code>, and on most images it is already
active:</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 unattended-upgrades</span></span></code></pre></div></div><p>With this, security updates are applied automatically. What does not happen
automatically is what was the problem above: the reboot.</p><p>This is exactly where the circle closes. Automatic updates<strong>without</strong> an
automatic reboot mean that kernel vulnerabilities are patched on disk but
never become active. The server looks well maintained and runs on a kernel
from half a year ago — the most unpleasant variant, because it feigns
security.</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">nano /etc/apt/apt.conf.d/52-unattended-upgrades-local</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>Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:30";
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";</code></pre></div><p>Check whether the settings take effect:</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">apt-config dump<span class="p">|</span> grep -i<span class="s1">'Automatic-Reboot'</span></span></span><span class="line"><span class="cl">unattended-upgrade --dry-run</span></span></code></pre></div></div><h4 id="this-decision-has-to-be-made">This Decision Has to Be Made</h4><p>An automatic reboot at half past three in the morning terminates every running
session of the application. With Vaadin this is more noticeable than with many
other frameworks, because the UI state is held on the server — Part 1 covers
this in detail.</p><p>Anyone who enables the reboot accepts a nightly maintenance window. Anyone who
leaves it out accepts unpatched kernels. Both are defensible. Remaining
undecided is not, because then the default applies — and it reads: patch, but
do not reboot.</p><h2 id="creating-an-administrative-user">Creating an Administrative User</h2><p>So far there is exactly one user on the system, and it may do everything. That
is about to change: administrative work will from now on run through a
dedicated account that obtains privileges when needed instead of holding them
permanently.</p><p>The practical gain is less philosophical than it sounds. An administrative
account without permanent privileges makes typos harmless that, as<code>root</code>,
would damage the system. And it is the precondition for completely disabling
root access via SSH in the next chapter.</p><h3 id="creating-the-user">Creating the User</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">adduser --disabled-password --gecos<span class="s2">"First Last"</span> sven</span></span><span class="line"><span class="cl">passwd sven</span></span><span class="line"><span class="cl">usermod -aG sudo sven</span></span></code></pre></div></div><p><code>--disabled-password</code> initially creates the account without a password;<code>passwd</code> then sets one deliberately. The detour seems cumbersome, but it
avoids the interactive dialog of<code>adduser</code> and can therefore also be used in a
script.</p><p>The password is not needed for logging in — that runs via a key. It is needed
for<code>sudo</code>.</p><h3 id="the-sudo-group">The<code>sudo</code> Group</h3><p>On Debian, membership in the group<code>sudo</code> is sufficient. A dedicated rule in<code>/etc/sudoers</code> is not necessary and would be an additional source of errors.</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">getent group sudo</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>sudo:x:27:sven</code></pre></div><p>Anyone who wants to set up<code>sudo</code> without a password prompt should consider
the decision carefully: it turns every compromised user account immediately
into full access. On a server that runs a publicly reachable application, the
password prompt is the right default.</p><h3 id="depositing-the-ssh-key">Depositing the SSH Key</h3><p>From the workstation, not 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">ssh-copy-id -i ~/.ssh/id_ed25519.pub sven@198.51.100.10</span></span></code></pre></div></div><p><code>ssh-copy-id</code> creates the directory with the correct permissions and appends
the key. It can be done by hand as well, just with more opportunities for
mistakes:</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">install -d -m<span class="m">700</span> -o sven -g sven /home/sven/.ssh</span></span><span class="line"><span class="cl">nano /home/sven/.ssh/authorized_keys</span></span><span class="line"><span class="cl">chmod<span class="m">600</span> /home/sven/.ssh/authorized_keys</span></span><span class="line"><span class="cl">chown sven:sven /home/sven/.ssh/authorized_keys</span></span></code></pre></div></div><p>The permissions are not optional.<code>sshd</code> refuses service if<code>authorized_keys</code>
is readable by others — a frequent reason for a key login that fails for no
apparent reason.</p><p>To check which keys are actually deposited:</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-keygen -lf /home/sven/.ssh/authorized_keys</span></span></code></pre></div></div><h3 id="verifying-access-before-hardening">Verifying Access Before Hardening</h3><p>This is the most important step of this chapter, and it is regularly skipped.</p><p>In the next chapter, root access is disabled. Anyone who discovers at that
point that the new user does not work has locked themselves out and needs the
provider&rsquo;s emergency console.</p><p>So beforehand, in a<strong>second session</strong> — the existing one stays open:</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 -i ~/.ssh/id_ed25519 sven@198.51.100.10<span class="s1">'id'</span></span></span><span class="line"><span class="cl">ssh -i ~/.ssh/id_ed25519 sven@198.51.100.10<span class="s1">'sudo whoami'</span></span></span></code></pre></div></div><p>The first command must print the user identity, the second<code>root</code>. Only when
both are correct does it go on.</p><p>The open session as<code>root</code> is not a blemish but the safety rope: as long as it
exists, any faulty change to the SSH configuration can be rolled back, even if
no new login succeeds anymore.</p><h2 id="hardening-ssh">Hardening SSH</h2><p>SSH is the only service on this server that is reachable from outside, and it
currently accepts passwords for<code>root</code>. Both change now.</p><h3 id="where-debian-reads-the-configuration">Where Debian Reads the Configuration</h3><p>Debian 13 splits the SSH configuration into a main file and a directory of
drop-in files. A look at both is worthwhile before changing anything:</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 -n -i<span class="s1">'^include'</span> /etc/ssh/sshd_config</span></span><span class="line"><span class="cl">grep -nvE<span class="s1">'^\s*(#|$)'</span> /etc/ssh/sshd_config<span class="p">|</span> grep -i permitroot</span></span><span class="line"><span class="cl">ls -1 /etc/ssh/sshd_config.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>12:Include /etc/ssh/sshd_config.d/*.conf
33:PermitRootLogin yes
50-cloud-init.conf</code></pre></div><p>And in this drop-in file it says:</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>PasswordAuthentication yes</code></pre></div><h3 id="the-pitfall-that-trips-up-most-people">The Pitfall That Trips Up Most People</h3><p>From these three observations follows something that contradicts intuition:</p><p><strong><code>sshd</code> uses the first value found per parameter, not the last.</strong></p><p>Two consequences follow from this, and both lead to a change remaining without
effect, with no error message appearing.</p><p><strong>First:</strong> The<code>Include</code> line is on line 12, the<code>PermitRootLogin yes</code> on
line 33. Everything from the drop-in directory is therefore read<strong>before</strong>
the main file and wins. Anyone who changes<code>PermitRootLogin</code> in<code>/etc/ssh/sshd_config</code> changes a value that would never take effect anyway, as
soon as a drop-in file sets the same parameter.</p><p><strong>Second:</strong> The files in the drop-in directory are read in alphabetical
order.<code>cloud-init</code> places<code>50-cloud-init.conf</code> there and sets<code>PasswordAuthentication yes</code>. A file of your own only takes effect if its name
sorts<strong>before</strong> it. Hence<code>10-hardening.conf</code> — and not, as intuition
suggests,<code>99-hardening.conf</code>.</p><p>Anyone who creates a<code>99-</code> file ends up with a file whose content is correct
and which accomplishes nothing. The directory looks right; the configuration
is not.</p><h3 id="the-hardening">The Hardening</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/ssh/sshd_config.d/10-hardening.conf</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># sshd uses the FIRST value it finds per parameter.
# This file must sort lexically BEFORE 50-cloud-init.conf,
# which sets PasswordAuthentication back to "yes".
# Authentication
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
# Only explicitly named accounts may log in
AllowUsers sven
MaxAuthTries 3
LoginGraceTime 30
MaxSessions 4
# Forwarding is not needed on an application server
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
GatewayPorts no
# Reduce host keys to ed25519
HostKey /etc/ssh/ssh_host_ed25519_key</code></pre></div><p>Three entries deserve a justification.</p><p><strong><code>AllowUsers</code></strong> is the most effective of them. Without this line, every
account that at some point receives a key may log in — including a service
account where nobody intended that. With it, the list of permitted accounts is
explicit and readable.</p><p><strong><code>AllowTcpForwarding no</code></strong> prevents the server from serving as a jump host.
Without this setting, anyone who is allowed to log in can tunnel arbitrary
connections through the server — including into networks that are not
reachable from outside.</p><p><strong><code>HostKey</code></strong> reduces the offered server keys to one. As soon as the parameter
is set, the defaults no longer apply. The result is a single fingerprint
instead of three — which is what makes the comparison from Chapter 3
unambiguous in the first place.</p><h3 id="checking-before-reloading">Checking Before Reloading</h3><p>Two commands, and the second is the decisive one:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo sshd -t</span></span><span class="line"><span class="cl">sudo sshd -T<span class="p">|</span> grep -E<span class="s1">'^(permitrootlogin|passwordauthentication|allowusers)'</span></span></span></code></pre></div></div><p><code>sshd -t</code> checks only the syntax and stays silent on success.<code>sshd -T</code> shows
the<strong>effective</strong> configuration after resolving all drop-in files:</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>permitrootlogin no
passwordauthentication no
allowusers sven</code></pre></div><p>That is the only reliable answer. Reading the configuration file does not
answer the question — that is exactly where the pitfall from above goes
unnoticed. Anyone who uses<code>sshd -T</code> habitually cannot miss the ordering
problem.</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 ssh</span></span></code></pre></div></div><p><code>reload</code> instead of<code>restart</code>: existing sessions are preserved. If the
configuration renders access unusable, the open session from Chapter 5 is
still there and the change can be rolled back.</p><h3 id="proof-from-outside">Proof From Outside</h3><p>Claiming is not enough. Three cases, all from the workstation:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1) The admin user by key - must succeed</span></span></span><span class="line"><span class="cl">ssh -i ~/.ssh/id_ed25519 sven@198.51.100.10<span class="s1">'echo OK'</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 2) root - must be rejected</span></span></span><span class="line"><span class="cl">ssh root@198.51.100.10</span></span><span class="line"><span class="cl"><span class="c1"># root@198.51.100.10: Permission denied (publickey).</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># 3) Password login - must be rejected</span></span></span><span class="line"><span class="cl">ssh -o<span class="nv">PubkeyAuthentication</span><span class="o">=</span>no -o<span class="nv">PreferredAuthentications</span><span class="o">=</span>password sven@198.51.100.10</span></span><span class="line"><span class="cl"><span class="c1"># sven@198.51.100.10: Permission denied (publickey).</span></span></span></code></pre></div></div><p>The proof is in the third case, specifically in the wording of the error
message. It says<code>publickey</code> — although a password login was explicitly
requested. So the server no longer offers passwords at all. If it still had
them on offer, it would say<code>password</code>.</p><p>For comparison, it is worth looking back at Chapter 3: there the answer was
still<code>Permission denied (publickey,password)</code>.</p><h3 id="what-is-offered-afterwards">What Is Offered Afterwards</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">ssh-keyscan 198.51.100.10 &gt; /tmp/hk.txt<span class="o">&amp;&amp;</span> ssh-keygen -lf /tmp/hk.txt</span></span></code></pre></div></div><p>Instead of three lines, one now appears. The fingerprint that a new machine
must confirm on the first connection is thus uniquely determined.</p><h2 id="configuring-the-firewall">Configuring the Firewall</h2><h3 id="how-fast-the-first-attacks-arrive">How Fast the First Attacks Arrive</h3><p>Before it comes to rules, a look into the log is worthwhile — because it
answers the question of whether the effort is worth it with numbers instead of
assumptions.</p><p>The reference server was rebooted after the kernel update from Chapter 4 at<strong>08:01:25</strong>. The first foreign login attempt appears in the journal at<strong>08:01:48</strong>:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">code</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code>2026-09-04T08:01:48+00:00 sshd-session[1018]:
Failed password for root from 31.58.216.82 port 50496</code></pre></div><p><strong>Twenty-three seconds.</strong> At that point no service was published, no domain
pointed to the address, and nobody had mentioned it anywhere. A public IPv4
address is enough: the entire address space is being swept permanently.</p><p>How permanently is shown by the evaluation over different time spans:</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 ssh<span class="p">|</span> grep -cE<span class="s1">'Failed password|Invalid user'</span></span></span></code></pre></div></div><table><thead><tr><th>Time span</th><th>Failed attempts</th><th>Distinct addresses</th></tr></thead><tbody><tr><td>last hour</td><td>219</td><td>12</td></tr><tr><td>since the reboot (2.5 hours)</td><td>608</td><td>23</td></tr><tr><td>over roughly eleven weeks</td><td><strong>1,121,423</strong></td><td>—</td></tr></tbody></table><p>Over a million login attempts on a server that does nothing and that nobody
knows. That is roughly 150 per hour, without interruption, day and night.</p><p>It is revealing which account names are being tried:</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>19739 admin 6311 test
15062 ubuntu 5558 debian
10225 user 5429 deploy
7153 solana 4960 sol</code></pre></div><p><code>admin</code>,<code>ubuntu</code>,<code>user</code>,<code>debian</code>,<code>deploy</code> — the usual suspects. But<code>solana</code> in fourth place and<code>sol</code> in eighth: there is a targeted search for
crypto wallet nodes. The attempts are not blind; they are profit-oriented.</p><p>That is the reason why root access and password login were disabled in
Chapter 6 — not as a formality, but because 150 attempts per hour run against
them. With password login enabled, it is a question of time and password
quality.</p><h3 id="defining-the-rules">Defining the Rules</h3><p>Debian does not ship<code>ufw</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 apt-get install ufw</span></span></code></pre></div></div><div class="pull-quote"><p><strong>Allow first, then enable.</strong> In the reverse order,<code>ufw enable</code> cuts off
your own SSH session, and the server is only reachable via the provider&rsquo;s
emergency console. That is the classic way to lock yourself out.</p></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 ufw default deny incoming</span></span><span class="line"><span class="cl">sudo ufw default allow outgoing</span></span><span class="line"><span class="cl">sudo ufw allow 22/tcp comment<span class="s1">'SSH'</span></span></span><span class="line"><span class="cl">sudo ufw allow 80/tcp comment<span class="s1">'HTTP'</span></span></span><span class="line"><span class="cl">sudo ufw allow 443/tcp comment<span class="s1">'HTTPS'</span></span></span><span class="line"><span class="cl">sudo ufw enable</span></span></code></pre></div></div><p>Three ports, nothing more.<strong>The application port deliberately stays closed.</strong>
In Part 1, a servlet container listens on port 8080 — but exclusively on the
loopback address, reachable only through the reverse proxy. Blocking it in the
firewall as well is the second line of defense; the first is not listening to
the outside there in the first place.</p><p>Port 80 is needed even though the application is later supposed to be
reachable exclusively via HTTPS: the automatic certificate issuance in Part 1
runs through a challenge on port 80.</p><h3 id="proof-from-outside-1">Proof From Outside</h3><p>From the workstation, not from 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"><span class="k">for</span> p in<span class="m">22</span><span class="m">80</span><span class="m">443</span> 8080<span class="p">;</span><span class="k">do</span></span></span><span class="line"><span class="cl"> nc -z -w<span class="m">4</span> 198.51.100.10<span class="nv">$p</span><span class="o">&amp;&amp;</span><span class="nb">echo</span><span class="s2">"</span><span class="nv">$p</span><span class="s2"> open"</span><span class="o">||</span><span class="nb">echo</span><span class="s2">"</span><span class="nv">$p</span><span class="s2"> filtered"</span></span></span><span class="line"><span class="cl"><span class="k">done</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>22 open
80 filtered
443 filtered
8080 filtered</code></pre></div><p>80 and 443 are allowed in<code>ufw</code>, but nothing is listening on them yet — they
only become reachable with the reverse proxy in Part 1. That is the expected
intermediate state and not an error.</p><h3 id="slowing-down-repeated-attempts">Slowing Down Repeated Attempts</h3><p>Given the numbers above, a plain port allowance is too little. Two measures
complement each other.</p><p><strong>Rate limiting</strong> directly in<code>ufw</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 ufw delete allow 22/tcp</span></span><span class="line"><span class="cl">sudo ufw limit 22/tcp comment<span class="s1">'SSH (rate-limited)'</span></span></span></code></pre></div></div><p><code>limit</code> instead of<code>allow</code> drops connections when more than six arrive from
the same source within 30 seconds.</p><p><strong><code>fail2ban</code></strong> evaluates the logs and locks out conspicuous addresses for a
while:</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 fail2ban</span></span><span class="line"><span class="cl">sudo nano /etc/fail2ban/jail.local</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">[DEFAULT]</span></span></span><span class="line"><span class="cl"><span class="c1"># Debian 13 logs to journald, not to /var/log/auth.log</span></span></span><span class="line"><span class="cl"><span class="na">backend</span><span class="o">=</span><span class="s">systemd</span></span></span><span class="line"><span class="cl"><span class="c1"># Apply bans through ufw so that only ONE rule set exists</span></span></span><span class="line"><span class="cl"><span class="na">banaction</span><span class="o">=</span><span class="s">ufw</span></span></span><span class="line"><span class="cl"><span class="na">ignoreip</span><span class="o">=</span><span class="s">127.0.0.1/8 ::1</span></span></span><span class="line"><span class="cl"><span class="na">bantime</span><span class="o">=</span><span class="s">1h</span></span></span><span class="line"><span class="cl"><span class="na">findtime</span><span class="o">=</span><span class="s">10m</span></span></span><span class="line"><span class="cl"><span class="na">maxretry</span><span class="o">=</span><span class="s">5</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="k">[sshd]</span></span></span><span class="line"><span class="cl"><span class="na">enabled</span><span class="o">=</span><span class="s">true</span></span></span></code></pre></div></div><p>Both lines in the header are Debian-13-specific and easily overlooked. Without<code>backend = systemd</code>,<code>fail2ban</code> looks for a file that no longer exists.
Without<code>banaction = ufw</code>, it builds a second, competing ruleset next to<code>ufw</code>
— both then work, but nobody can see as a whole anymore what is blocked.</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<span class="nb">enable</span> --now fail2ban</span></span><span class="line"><span class="cl">sudo fail2ban-client status sshd</span></span></code></pre></div></div><p>The effect is immediately visible:</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>Status for the jail: sshd
`- Actions
|- Currently banned: 2
`- Banned IP list: 104.248.160.169 173.244.60.241</code></pre></div><p><code>fail2ban</code> evaluates the existing journal at startup and immediately bans what
was already conspicuous there. You do not have to wait to see an effect.</p><h4 id="what-this-achieves-and-what-it-does-not">What This Achieves and What It Does Not</h4><p>With key-only login, the<code>sshd</code> jail protects little — anyone without a
matching key does not get through anyway, no matter how often they try. The
gain lies elsewhere: less system load, a readable log, and a safety net for
the case that someone later re-enables password login by accident.</p><p>As the only measure,<code>fail2ban</code> would be weak. As a complement to Chapter 6,
it is right.</p><div class="pull-quote"><h3 id="the-rate-limit-also-hits-your-own-tools">The Rate Limit Also Hits Your Own Tools</h3><p>Directly after<code>ufw limit 22/tcp</code>, your own connection can drop:</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>ssh: connect to host 198.51.100.10 port 22: Connection refused</code></pre></div><p>That is neither an attack nor an outage. A sequence of<code>ssh</code>,<code>scp</code>, and<code>ssh-keyscan</code> opens more than six connections within a few seconds and
thereby exceeds your own threshold. After the time window expires, access is
there unchanged.</p><p>Anyone who runs scripts that open many short SSH connections should plan for
this:<code>ControlMaster</code> and<code>ControlPersist</code> in the SSH configuration keep one
connection open and reuse it, instead of establishing a new one for every
command.</p></div><h3 id="outbound-connections">Outbound Connections</h3><p>So far, everything outbound is open. Restricting this limits what a
compromised process can download and where it can exfiltrate data to:</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 ufw default deny outgoing</span></span><span class="line"><span class="cl">sudo ufw allow out<span class="m">53</span> comment<span class="s1">'DNS'</span></span></span><span class="line"><span class="cl">sudo ufw allow out 80/tcp comment<span class="s1">'HTTP: apt, Zertifikate'</span></span></span><span class="line"><span class="cl">sudo ufw allow out 443/tcp comment<span class="s1">'HTTPS: apt, Zertifikate'</span></span></span><span class="line"><span class="cl">sudo ufw allow out 123/udp comment<span class="s1">'NTP'</span></span></span><span class="line"><span class="cl">sudo ufw reload</span></span></code></pre></div></div><p><strong>Do not forget NTP.</strong> Without a synchronized clock, the verification of TLS
certificates fails, and the automatic certificate renewal in Part 1 breaks.
That is the error that only shows up weeks later.</p><p>Afterwards, check that what must work still works:</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 update</span></span><span class="line"><span class="cl">timedatectl show -p NTPSynchronized --value</span></span></code></pre></div></div><h4 id="an-honest-assessment">An Honest Assessment</h4><p>The effect of this restriction has limits. Port 443 must remain open, and via
443 practically every destination on the Internet is reachable. Anyone who
wants to exfiltrate data chooses HTTPS for it, not an exotic port.</p><p>The gain lies with everything that does not follow this rule: a connection to
some remote endpoint on port 4444, mail delivery over port 25, a tunnel on an
arbitrary high port. That is real, but it is not a barrier — and it should not
be described as one.</p><h2 id="hardening-kernel-parameters">Hardening Kernel Parameters</h2><p>The previous chapters secured the system towards the outside. This chapter
turns the view inward: what may a process on this server find out about other
processes and about the kernel?</p><p>Debian already sets most security-relevant kernel parameters sensibly. Two,
however, are set to the least secure value.</p><h3 id="the-parameter-that-matters">The Parameter That Matters</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">sysctl -n kernel.yama.ptrace_scope</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>0</code></pre></div><p><code>ptrace</code> is the mechanism a debugger uses to read the memory of another
process. At<code>0</code>, this access is allowed for every process on every other
process of the same user.</p><p>The impact shows in the interplay with a topic from Part 1. There, the point
is to keep credentials out of command-line arguments, because those are
visible to every user of the system in the process list. The effort is
justified — but it evaporates as long as another process may simply read the
same value from memory.</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>kernel.yama.ptrace_scope = 1</code></pre></div><p>With<code>1</code>, a process may only inspect its own child processes. A debugger still
works if it starts the target program itself; attaching to a foreign process
afterwards requires root privileges.</p><p>This is a pattern that recurs throughout this series:<strong>individual measures
only work in combination.</strong> A carefully secured handover of credentials and
open<code>ptrace</code> access cancel each other out.</p><h3 id="the-remaining-values">The Remaining Values</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/sysctl.d/60-hardening.conf</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># Make the memory of other processes unreadable
kernel.yama.ptrace_scope = 1
# Do not expose kernel pointers in /proc
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
net.core.bpf_jit_harden = 2
# Network
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.log_martians = 1
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_source_route = 0
# File system
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
fs.suid_dumpable = 0</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 sysctl --system</span></span></code></pre></div></div><p><code>kernel.kptr_restrict</code> hides kernel memory addresses in<code>/proc</code>. They are
needed for developing attacks, but by nothing in normal operation.</p><p>The network parameters reject redirects and source-routed packets — techniques
from an era when trust on the network was the default. The file system
parameters prevent a class of attacks in which an unprivileged user plants a
link in a shared directory.</p><h3 id="verification">Verification</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="k">for</span> k in kernel.yama.ptrace_scope kernel.kptr_restrict net.core.bpf_jit_harden<span class="p">;</span><span class="k">do</span></span></span><span class="line"><span class="cl"><span class="nb">printf</span><span class="s1">'%-32s %s\n'</span><span class="s2">"</span><span class="nv">$k</span><span class="s2">"</span><span class="s2">"</span><span class="k">$(</span>sysctl -n<span class="nv">$k</span><span class="k">)</span><span class="s2">"</span></span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div></div><p>The values now read<code>1</code>,<code>2</code>, and<code>2</code>. Because the file lives under<code>/etc/sysctl.d/</code>, they also apply after a reboot.</p><h2 id="user-and-permission-concept">User and Permission Concept</h2><p>On this server, three kinds of accounts are distinguished from now on. The
administrative account from Chapter 5 is one of them; this chapter adds the
second and explains why the third must not exist.</p><table><thead><tr><th>Account</th><th>Purpose</th><th>Login</th></tr></thead><tbody><tr><td><code>root</code></td><td>system administration via<code>sudo</code></td><td>none, neither via SSH nor by password</td></tr><tr><td>administrative account</td><td>administration by humans</td><td>key,<code>sudo</code> with password</td></tr><tr><td>service account</td><td>runs the application</td><td>none</td></tr></tbody></table><p>What does not exist: an account that is both. Running an application under the
administrative account saves one step and costs the entire separation — the
application process would then have access to the SSH keys and, via<code>sudo</code>, to
the whole system.</p><h3 id="the-service-account">The Service Account</h3><p>The later application is called<code>vaadinapp</code>, and its account is called the
same:</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 adduser --system --group<span class="se">\</span></span></span><span class="line"><span class="cl"> --home /var/lib/vaadinapp<span class="se">\</span></span></span><span class="line"><span class="cl"> --shell /usr/sbin/nologin<span class="se">\</span></span></span><span class="line"><span class="cl"> vaadinapp</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">id vaadinapp</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>uid=101(vaadinapp) gid=103(vaadinapp) groups=103(vaadinapp)</code></pre></div><p>Three details are worth explaining.</p><p><strong><code>--system</code></strong> assigns an ID below 1000. That is not a technical necessity,
but a lived convention: tools that list &ldquo;real&rdquo; users hide this range. The
command from Chapter 3 that searches for users with an ID of 1000 or above
therefore does not find the service account — and that is intended.</p><p><strong><code>--shell /usr/sbin/nologin</code></strong> prevents any interactive session. Even if
someone were to set a password or deposit a key, no login would come about.</p><p><strong><code>--home /var/lib/vaadinapp</code></strong> places the home directory where the
application&rsquo;s mutable data lives. Chapter 10 covers the layout as a whole.</p><h3 id="one-account-per-application">One Account per Application</h3><p>The obvious shortcut would be a shared account for all Java applications —<code>jetty</code>, say. It saves administrative effort and cancels exactly the property
this is all about.</p><p>Two applications under the same account can read each other&rsquo;s files, inspect
each other&rsquo;s credentials, and — with a<code>ptrace_scope</code> of<code>0</code>, see Chapter 8 —
read each other&rsquo;s memory. Whoever compromises one of them has both.</p><p>One account per application costs one command. This calculation always works
out.</p><h3 id="the-service-must-not-be-able-to-change-its-own-program">The Service Must Not Be Able to Change Its Own Program</h3><p>The most important principle of this chapter is at the same time the most
rarely implemented one:<strong>the service account does not own its own program
files.</strong></p><p>Whoever compromises an application can do what the application may do — but
they cannot replace the program on disk. A restart brings back the unmodified
state. Without this separation, a manipulation survives every restart, and the
cause is later almost impossible to find.</p><p>In practical terms: the program directory and the configuration belong to<code>root</code>; the service account may read. Writable is exclusively the area for
mutable data. Chapter 10 implements this.</p><h3 id="a-note-on-dynamicuser">A Note on<code>DynamicUser</code></h3><p><code>systemd</code> can create service accounts itself at every start and discard them
afterwards. That saves creating them by hand and leaves no stale accounts
behind.</p><p>For this series it is not used. The reason is the section above: permanent
ownership requires a permanent ID. With a changing ID, it cannot be determined
which files belong to the service and which do not — and that is exactly what
the protection rests on.</p><p>For stateless services without files of their own,<code>DynamicUser</code> is a good
choice. An application with a data set is not such a service.</p><h2 id="directory-structure-for-applications">Directory Structure for Applications</h2><p>An application consists of three kinds of files that have nothing to do with
one another: the program, its configuration, and the data that accumulates
during operation. They differ in who may change them, how often they change,
and whether they belong in a backup.</p><p>A directory in which everything lies side by side is convenient and makes each
of these questions unanswerable.</p><h3 id="the-layout">The Layout</h3><p>The Filesystem Hierarchy Standard has answered the question for decades. For
an application named<code>vaadinapp</code>:</p><table><thead><tr><th>Path</th><th>Contents</th><th>Owner</th><th>Service may</th></tr></thead><tbody><tr><td><code>/opt/vaadinapp</code></td><td>program, artifact</td><td><code>root:vaadinapp</code></td><td>read</td></tr><tr><td><code>/etc/vaadinapp</code></td><td>configuration</td><td><code>root:vaadinapp</code></td><td>read</td></tr><tr><td><code>/var/lib/vaadinapp</code></td><td>mutable data</td><td><code>vaadinapp:vaadinapp</code></td><td><strong>write</strong></td></tr><tr><td>journald</td><td>logs</td><td>—</td><td>write</td></tr></tbody></table><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</span></span><span class="line"><span class="cl">sudo install -d -m<span class="m">750</span> -o root -g vaadinapp /etc/vaadinapp</span></span><span class="line"><span class="cl">sudo install -d -m<span class="m">750</span> -o vaadinapp -g vaadinapp /var/lib/vaadinapp</span></span></code></pre></div></div><p><code>install -d</code> instead of<code>mkdir</code> sets permissions and owner in one call. With<code>mkdir</code>, two more commands follow, and the second is easily forgotten.</p><p>The permissions<code>750</code> mean: the owner may do everything, the group may read
and enter, everyone else may do nothing. Since the service account is in the
group of the same name, it can read — and write only where it is itself the
owner.</p><h3 id="logs">Logs</h3><p><code>/var/log/vaadinapp</code> is deliberately absent from the table. Services running
under<code>systemd</code> write their output to<code>journald</code>; a separate log directory
creates a second store with its own rotation and its own cleanup needs.</p><p>Applications that additionally keep an access log of their own — in Part 1
that will be the case — place it below<code>/var/lib/vaadinapp</code>. Writing happens
there anyway, and it stays at one writable area instead of two.</p><h3 id="why-this-separation-is-more-than-tidiness">Why This Separation Is More Than Tidiness</h3><p>Here is the point at which this chapter goes beyond housekeeping.</p><p>In Part 1, the application service is set up so that<code>systemd</code> presents it the
entire file system read-only. Released is only what is explicitly named. With
clean separation, that is<strong>a single line</strong>: the area for mutable data.</p><p>If everything lay under one shared directory, exactly this directory would
have to be released — and with it the program and the configuration. The
measure would formally exist and would be practically ineffective.</p><p>These five minutes in Chapter 10 are the precondition for a security measure
in Part 1 to take hold at all. Anyone who treats the structure as a matter of
taste discovers later that the hardening does not work, and does not find the
reason.</p><h3 id="a-subtlety-that-surprises">A Subtlety That Surprises</h3><p>It is not enough to make the program directory belong to<code>root</code>. What also
matters is who owns the<strong>parent</strong> directory.</p><p>Whoever may write to a directory may rename the entries it contains —
regardless of who owns them. A service with write access to its home directory
can therefore push aside a<code>root</code>-owned subdirectory inside it and put its own
in its place. It never touched the files inside; yet it achieved exactly what
was supposed to be prevented.</p><p>That is why the parent directory also belongs to<code>root</code>, and writable are only
the subdirectories in which writing actually happens:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">bash</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo install -d -m<span class="m">750</span> -o root -g vaadinapp /var/lib/vaadinapp</span></span><span class="line"><span class="cl">sudo install -d -m<span class="m">750</span> -o vaadinapp -g vaadinapp /var/lib/vaadinapp/data</span></span><span class="line"><span class="cl">sudo install -d -m<span class="m">750</span> -o vaadinapp -g vaadinapp /var/lib/vaadinapp/work</span></span></code></pre></div></div><h3 id="verification-1">Verification</h3><p>The test for it is simple — it checks what the service account is actually
allowed to do:</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="k">for</span> d in /opt/vaadinapp /etc/vaadinapp /var/lib/vaadinapp /var/lib/vaadinapp/data<span class="p">;</span><span class="k">do</span></span></span><span class="line"><span class="cl"> sudo -u vaadinapp<span class="nb">test</span> -w<span class="s2">"</span><span class="nv">$d</span><span class="s2">"</span><span class="o">&amp;&amp;</span><span class="nv">s</span><span class="o">=</span><span class="s2">"schreibbar"</span><span class="o">||</span><span class="nv">s</span><span class="o">=</span><span class="s2">"read-only"</span></span></span><span class="line"><span class="cl"><span class="nb">printf</span><span class="s1">'%-32s %-11s %s\n'</span><span class="s2">"</span><span class="nv">$d</span><span class="s2">"</span><span class="s2">"</span><span class="nv">$s</span><span class="s2">"</span><span class="s2">"</span><span class="k">$(</span>stat -c<span class="s1">'%U:%G %a'</span><span class="nv">$d</span><span class="k">)</span><span class="s2">"</span></span></span><span class="line"><span class="cl"><span class="k">done</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>/opt/vaadinapp read-only root:vaadinapp 750
/etc/vaadinapp read-only root:vaadinapp 750
/var/lib/vaadinapp read-only root:vaadinapp 750
/var/lib/vaadinapp/data schreibbar vaadinapp:vaadinapp 750</code></pre></div><p>Exactly one writable path. That is the goal.</p><h2 id="initial-state-for-part-1">Initial State for Part 1</h2><p>The server is prepared. This chapter records what has been achieved — as a
list that can be checked off, and as a set of commands that prove the state
instead of asserting it.</p><p>Anyone who skipped this part because their own server is already run in a
hardened state can cross-check here and then move on to Part 1.</p><h3 id="what-applies-now">What Applies Now</h3><table><thead><tr><th>Area</th><th>State</th></tr></thead><tbody><tr><td>System packages</td><td>current, automatic security updates with nightly reboot</td></tr><tr><td>Kernel</td><td>the installed version is also the one running</td></tr><tr><td>Login</td><td>exclusively by key, exclusively for named accounts</td></tr><tr><td><code>root</code></td><td>not reachable via SSH</td></tr><tr><td>Firewall, inbound</td><td>22 (rate-limited), 80, 443</td></tr><tr><td>Firewall, outbound</td><td>DNS, HTTP, HTTPS, NTP</td></tr><tr><td>Repeated failed attempts</td><td>get banned</td></tr><tr><td>Kernel parameters</td><td>hardened, in particular<code>ptrace_scope</code></td></tr><tr><td>Administrative account</td><td>present, with<code>sudo</code></td></tr><tr><td>Service account</td><td>present, with no way to log in</td></tr><tr><td>Directories</td><td>program, configuration, and data separated</td></tr></tbody></table><h3 id="the-proof">The Proof</h3><figure><img src="/images/2026/09/vaadin-deployment-on-hetzner-part-0-preparing-a-debian-server-teil00-zielzustand.png" alt="Access path after Part 0" loading="lazy" decoding="async"/><p><em>Figure 2: The access path after completing this part.</em></p><p>These commands answer the list above. They run in a few seconds and are worth
it, because reading a configuration file is something different from checking
the effective state.</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"># Is the installed kernel the one running?</span></span></span><span class="line"><span class="cl">uname -r</span></span><span class="line"><span class="cl">ls -1 /boot/vmlinuz-*<span class="p">|</span> sort -V<span class="p">|</span> tail -1</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Effective SSH configuration</span></span></span><span class="line"><span class="cl">sudo sshd -T<span class="p">|</span> grep -E<span class="s1">'^(permitrootlogin|passwordauthentication|allowusers)'</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Firewall</span></span></span><span class="line"><span class="cl">sudo ufw status verbose</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Ban mechanism</span></span></span><span class="line"><span class="cl">sudo fail2ban-client status sshd</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Kernel parameters</span></span></span><span class="line"><span class="cl">sysctl -n kernel.yama.ptrace_scope kernel.kptr_restrict</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># Accounts</span></span></span><span class="line"><span class="cl">getent passwd vaadinapp</span></span><span class="line"><span class="cl">getent group sudo</span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="c1"># What is listening to the outside?</span></span></span><span class="line"><span class="cl">ss -tlnp</span></span></code></pre></div></div><p>The last command should still show only<code>sshd</code>. No application is running on
this server yet — that changes with Part 1.</p><p>And from another machine, 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">ssh root@198.51.100.10</span></span><span class="line"><span class="cl"><span class="c1"># Permission denied (publickey).</span></span></span><span class="line"><span class="cl"/></span><span class="line"><span class="cl"><span class="k">for</span> p in<span class="m">22</span><span class="m">80</span><span class="m">443</span> 8080<span class="p">;</span><span class="k">do</span></span></span><span class="line"><span class="cl"> nc -z -w<span class="m">4</span> 198.51.100.10<span class="nv">$p</span><span class="o">&amp;&amp;</span><span class="nb">echo</span><span class="s2">"</span><span class="nv">$p</span><span class="s2"> open"</span><span class="o">||</span><span class="nb">echo</span><span class="s2">"</span><span class="nv">$p</span><span class="s2"> filtered"</span></span></span><span class="line"><span class="cl"><span class="k">done</span></span></span><span class="line"><span class="cl"><span class="c1"># 22 open, everything else filtered</span></span></span></code></pre></div></div><h3 id="what-part-1-finds-in-place">What Part 1 Finds in Place</h3><p>Part 1 builds on exactly this state and does not repeat it. It installs a Java
runtime and a servlet container, gets a Vaadin application running on it as a
web archive, places it as a<code>systemd</code> service under the prepared service
account, and makes it publicly reachable through a reverse proxy with an
automatically issued certificate.</p><p>Two things from this part will resurface there. The directory separation from
Chapter 10 becomes the basis for effectively confining the application
service. And the numbers from Chapter 7 get a sequel: as soon as the
application receives a certificate, it becomes publicly known — faster than
you would think possible.</p>
]]></content:encoded><category>Java</category><category>Security</category><media:content url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-0-preparing-a-debian-server-hero.png" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-0-preparing-a-debian-server-hero.png"/><enclosure url="https://svenruppert.com/images/2026/09/vaadin-deployment-on-hetzner-part-0-preparing-a-debian-server-hero.png" type="image/jpeg" length="0"/></item></channel></rss>