<?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/"><channel><title>Homelab on Kabelbruch</title><link>https://kabelbruch.de/categories/homelab/</link><description>Recent content in Homelab on Kabelbruch</description><image><title>Kabelbruch</title><url>https://kabelbruch.de/og-image.png</url><link>https://kabelbruch.de/og-image.png</link></image><generator>Hugo</generator><language>de-DE</language><lastBuildDate>Fri, 30 Oct 2026 23:45:00 +0100</lastBuildDate><atom:link href="https://kabelbruch.de/categories/homelab/index.xml" rel="self" type="application/rss+xml"/><item><title>Wie Kabelbruch entstanden ist – Teil 4: Mail, Impressum und Favicon</title><link>https://kabelbruch.de/posts/kabelbruch-teil-4-mail-impressum-favicon/</link><pubDate>Fri, 30 Oct 2026 23:45:00 +0100</pubDate><guid>https://kabelbruch.de/posts/kabelbruch-teil-4-mail-impressum-favicon/</guid><description>Eine Kontaktadresse, ein Mailserver, der nur noch im DNS existierte, SPF, DKIM und DMARC, Impressum und Datenschutz – und ein K mit Funken als Favicon.</description><content:encoded><![CDATA[<p>Nach <a href="/posts/kabelbruch-teil-3-pipeline/">Teil 3</a> war meine Seite technisch fertig: Ein <code>git push</code>, und zehn Sekunden später ist ein Artikel online. Fehlten nur noch die Dinge, die man gern bis zum Schluss aufschiebt. Eine Website in Deutschland braucht ein <strong>Impressum</strong> und eine <strong>Datenschutzerklärung</strong>, und beide brauchen eine <strong>Kontaktadresse</strong>, die auch funktioniert. Und im Browser-Tab hing statt eines Symbols eine leere Fläche – die Konsole beschwerte sich über fehlende Favicons.</p>
<p>In diesem letzten Teil der Serie geht es um genau diese Dinge. Und um einen Mailserver, den es nicht mehr gab, an den mein DNS aber weiterhin brav alle E-Mails geschickt hat.</p>
<h2 id="voraussetzungen">Voraussetzungen</h2>
<ul>
<li>Ein eigener Mailserver – bei mir <strong><a href="https://mailcow.email/" target="_blank" rel="noopener noreferrer">mailcow</a></strong>, eine Open-Source-Mailserver-Suite auf Docker-Basis, die schon länger für eine andere Domain läuft</li>
<li>Zugriff auf das DNS der Domain (bei mir Cloudflare)</li>
<li>Das Hugo-Projekt aus Teil 3</li>
</ul>
<p>Beispielwerte: <code>example.com</code> ist die Blog-Domain, <code>mail.example.net</code> mein mailcow-Server und <code>ich@example.net</code> mein persönliches Postfach.</p>
<h2 id="teil-a-eine-kontaktadresse-die-wirklich-ankommt">Teil A: Eine Kontaktadresse, die wirklich ankommt</h2>
<h3 id="die-entscheidung">Die Entscheidung</h3>
<p>Für die Kontaktadresse gab es drei Möglichkeiten: meine normale Adresse, eine Weiterleitung auf meiner anderen Domain oder eine Adresse <strong>auf der Blog-Domain selbst</strong>. Ich habe mich für die dritte entschieden: <code>kontakt@example.com</code>. Sie passt zur Seite, verrät nichts über meine anderen Domains, und ich kann sie jederzeit umleiten, ohne das Impressum zu ändern.</p>
<p>Ein eigenes Postfach braucht es dafür nicht. Ein <strong>Alias</strong> (eine Weiterleitungsadresse) auf mein bestehendes Postfach genügt.</p>
<h3 id="die-domain-in-mailcow-anlegen">Die Domain in mailcow anlegen</h3>
<p>In mailcow: <em>E-Mail → Konfiguration → Domains → Domain hinzufügen</em>. Beschreibung, Limits – für einen Alias spielen die Standardwerte keine Rolle. Neuere mailcow-Versionen erzeugen dabei gleich den <strong>DKIM-Schlüssel</strong> mit (Selektor <code>dkim</code>, 2048 Bit). Die Relay-Optionen bleiben leer.</p>
<p>Danach zeigt mailcow bei jeder Domain über den Button <strong>„DNS“</strong> eine Übersicht: welche DNS-Einträge es erwartet und was im DNS <strong>tatsächlich</strong> steht.</p>
<h3 id="gescheitert-aber-rechtzeitig-gemerkt-der-geister-mailserver">Gescheitert, aber rechtzeitig gemerkt: der Geister-Mailserver</h3>
<p>Diese Übersicht hat mich kurz stutzen lassen. Die Spalte „Current State“ war nämlich nicht leer:</p>
<table>
	<thead>
			<tr>
					<th>Eintrag</th>
					<th>mailcow erwartet</th>
					<th>Tatsächlich im DNS</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>MX</td>
					<td><code>mail.example.net</code></td>
					<td><code>mail.example.com</code></td>
			</tr>
			<tr>
					<td>autodiscover / autoconfig</td>
					<td><code>mail.example.net</code></td>
					<td><code>mail.example.com</code></td>
			</tr>
			<tr>
					<td>SPF</td>
					<td>–</td>
					<td><code>v=spf1 mx ip4:198.51.100.7 -all</code></td>
			</tr>
	</tbody>
</table>
<p>Für meine Blog-Domain gab es also schon Mail-Einträge – und die zeigten auf einen <strong>alten Mailserver</strong>, den es nicht mehr gab. Das heißt: Jede E-Mail an eine Adresse dieser Domain lief ins Leere.</p>
<p>Wäre der alte Server noch in Betrieb gewesen, hätte ich jetzt sehr vorsichtig sein müssen: erst alle Adressen in mailcow anlegen, alte Mails übernehmen (mailcow kann das per Sync-Job direkt vom alten Server), dann erst umstellen. So konnte ich direkt aufräumen.</p>
<p><strong>Learning:</strong> Vor jeder DNS-Änderung erst nachsehen, was <strong>jetzt</strong> drinsteht. Die DNS-Übersicht von mailcow ist dafür ideal, <code>dig</code> tut es auch:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig +short MX example.com @1.1.1.1
</span></span><span class="line"><span class="cl">dig +short TXT example.com @1.1.1.1
</span></span></code></pre></div><h3 id="aufräumen-bei-cloudflare">Aufräumen bei Cloudflare</h3>
<p>In der Cloudflare-Übersicht fanden sich noch mehr Reste. Hier die Liste, Zeile für Zeile:</p>
<p><strong>Gelöscht:</strong> die A- und AAAA-Einträge für <code>mail</code> – sie zeigten auf den alten Server.</p>
<p><strong>Geändert:</strong></p>
<table>
	<thead>
			<tr>
					<th>Eintrag</th>
					<th>neu</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>MX <code>@</code></td>
					<td><code>mail.example.net</code>, Priorität 10</td>
			</tr>
			<tr>
					<td>CNAME <code>autoconfig</code></td>
					<td><code>mail.example.net</code></td>
			</tr>
			<tr>
					<td>CNAME <code>autodiscover</code></td>
					<td><code>mail.example.net</code></td>
			</tr>
			<tr>
					<td>TXT <code>@</code> (SPF)</td>
					<td><code>v=spf1 mx -all</code></td>
			</tr>
			<tr>
					<td>TXT <code>www</code> (SPF)</td>
					<td><code>v=spf1 -all</code></td>
			</tr>
			<tr>
					<td>TXT <code>*</code> (SPF)</td>
					<td><code>v=spf1 -all</code></td>
			</tr>
	</tbody>
</table>
<p><strong>Neu angelegt:</strong></p>
<table>
	<thead>
			<tr>
					<th>Typ</th>
					<th>Name</th>
					<th>Inhalt</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>TXT</td>
					<td><code>_dmarc</code></td>
					<td><code>v=DMARC1; p=quarantine; adkim=s; aspf=s</code></td>
			</tr>
			<tr>
					<td>TXT</td>
					<td><code>dkim._domainkey</code></td>
					<td>der DKIM-Schlüssel aus mailcow (<code>v=DKIM1;k=rsa;…</code>)</td>
			</tr>
			<tr>
					<td>SRV</td>
					<td><code>_autodiscover._tcp</code></td>
					<td>Priorität 0, Gewicht 1, Port 443, Ziel <code>mail.example.net</code></td>
			</tr>
	</tbody>
</table>
<p>Alle Einträge stehen auf <strong>DNS only</strong>, also ohne Cloudflare-Proxy. Zwei weitere Altlasten eines früheren Domain-Anbieters (ein Eintrag für dessen Konfigurationsdienst und eine alte Domain-Verifizierung) habe ich stehen lassen – sie stören nicht.</p>
<h3 id="spf-dkim-und-dmarc-in-drei-sätzen">SPF, DKIM und DMARC in drei Sätzen</h3>
<p>Diese drei Einträge sorgen dafür, dass niemand so leicht E-Mails im Namen meiner Domain fälschen kann:</p>
<ul>
<li><strong>SPF</strong> (Sender Policy Framework) legt fest, <strong>welche Server</strong> Mails für die Domain verschicken dürfen. <code>v=spf1 mx -all</code> heißt: nur der Server aus dem MX-Eintrag, alle anderen werden abgelehnt. Für <code>www</code> und alle anderen Subdomains (<code>*</code>) steht <code>v=spf1 -all</code> – von dort kommt nie eine Mail, also darf auch niemand so tun.</li>
<li><strong>DKIM</strong> (DomainKeys Identified Mail) <strong>signiert</strong> jede ausgehende Mail. Den öffentlichen Schlüssel zum Prüfen veröffentlicht der <code>dkim._domainkey</code>-Eintrag.</li>
<li><strong>DMARC</strong> sagt anderen Mailservern, <strong>was sie tun sollen</strong>, wenn SPF oder DKIM nicht passen. <code>p=quarantine</code> heißt: in den Spam damit. <code>adkim=s</code> und <code>aspf=s</code> verlangen eine exakte Übereinstimmung der Domain. Weil meine Domain ausschließlich über mailcow mit DKIM verschickt, kann ich von Anfang an streng sein.</li>
</ul>
<p>Ein Tipp zum DKIM-Schlüssel: <strong>nicht aus einem Screenshot abtippen.</strong> Der Schlüssel ist eine lange Zeichenkette, in der <code>l</code> und <code>I</code> fröhlich durcheinandergehen. In mailcow steht er unter <em>Konfiguration → ARC/DKIM-Schlüssel</em> als markierbarer Text.</p>
<p>Nach ein paar Minuten zeigte die DNS-Übersicht von mailcow überall <strong>grüne Haken</strong> – auch beim DKIM-Schlüssel. Damit ist auch geprüft, dass er exakt stimmt.</p>
<h3 id="der-alias">Der Alias</h3>
<p>In mailcow: <em>E-Mail → Konfiguration → Aliasse → Alias hinzufügen</em>:</p>
<ul>
<li>Alias-Adresse: <code>kontakt@example.com</code></li>
<li>Ziel: <code>ich@example.net</code></li>
<li><strong>„Allow to send as this alias“</strong> ✓ – damit kann ich auch <strong>als</strong> <code>kontakt@</code> antworten, sauber mit DKIM signiert</li>
<li><strong>„Internal“</strong> leer lassen – sonst könnte mir von außen niemand schreiben</li>
</ul>
<p>Dazu habe ich gleich noch <code>postmaster@example.com</code> auf dasselbe Ziel angelegt. Eine <code>postmaster</code>-Adresse sollte jede Maildomain haben, dorthin schreiben andere Mailserver bei Problemen.</p>
<p>Der Test: eine Mail von einem fremden Anbieter an <code>kontakt@example.com</code> – nach wenigen Sekunden war sie in meinem Postfach.</p>
<h2 id="teil-b-impressum">Teil B: Impressum</h2>
<h3 id="brauche-ich-das-als-privater-blog">Brauche ich das als privater Blog?</h3>
<p>Kurz: <strong>ja.</strong> Ich bin kein Anwalt, aber die Lage ist ziemlich klar. Ein Blog, der für die Öffentlichkeit gedacht ist, braucht nach <strong>§ 18 Abs. 1 Medienstaatsvertrag (MStV)</strong> mindestens Name und Anschrift. Sobald er „geschäftsmäßig“ betrieben wird – und dafür reichen schon Werbebanner oder Affiliate-Links –, gelten zusätzlich die Pflichten aus <strong>§ 5 Digitale-Dienste-Gesetz (DDG)</strong>, dem Nachfolger des alten Telemediengesetzes. Dann braucht es unter anderem einen schnellen elektronischen Kontaktweg und einen zweiten Weg zur Kontaktaufnahme.</p>
<p>Die Anschrift muss <strong>ladungsfähig</strong> sein, also eine Adresse, an die Behörden Schriftstücke zustellen können. Ein Postfach reicht dafür nicht.</p>
<h3 id="meine-entscheidungen">Meine Entscheidungen</h3>
<ul>
<li><strong>Adresse:</strong> Ich habe meine Privatanschrift genommen. Die Alternative wäre ein Impressum-Service, der eine eigene Adresse stellt. Das schützt die Wohnadresse, ist rechtlich aber nicht ganz unumstritten. Für mich war die sichere Variante wichtiger.</li>
<li><strong>Aufbau:</strong> Werbung gibt es hier nicht, Affiliate-Links schließe ich für später aber nicht aus. Deshalb ist das Impressum gleich so aufgebaut, dass es auch nach § 5 DDG passt. Eine Telefonnummer als zweiter Kontaktweg kommt dazu, falls es so weit ist.</li>
<li><strong>Kontakt:</strong> die neue Adresse <code>kontakt@example.com</code>.</li>
</ul>
<p>In Hugo ist das Impressum einfach eine Markdown-Seite, <code>content/impressum.md</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">title</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Impressum&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;/impressum/&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ShowReadingTime</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ShowToc</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ShowBreadCrumbs</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ShowPostNavLinks</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Angaben gemäß § 5 DDG und § 18 Abs. 1 MStV
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Vorname Nachname\
</span></span><span class="line"><span class="cl">Straße Hausnummer\
</span></span><span class="line"><span class="cl">PLZ Ort\
</span></span><span class="line"><span class="cl">Deutschland
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Kontakt
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">E-Mail: kontakt<span class="ni">@example</span>.com
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">## Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Vorname Nachname, Anschrift wie oben
</span></span></code></pre></div><p>Der Backslash am Zeilenende erzwingt in Markdown einen Zeilenumbruch, ohne dass ein neuer Absatz entsteht – praktisch für Adressen. Dazu kommt bei mir noch ein kurzer Absatz zur Haftung für externe Links.</p>
<h2 id="teil-c-eine-datenschutzerklärung-die-stimmt">Teil C: Eine Datenschutzerklärung, die stimmt</h2>
<h3 id="erst-die-technik-dann-der-text">Erst die Technik, dann der Text</h3>
<p>Mein Grundsatz für die Datenschutzerklärung: <strong>Es steht nur drin, was technisch auch so ist.</strong> Klingt selbstverständlich, ist es aber nicht – viele Vorlagen versprechen Dinge, die der eigene Server gar nicht tut. Oder sie verschweigen Dinge, die er sehr wohl tut.</p>
<p>Bei mir ist die Lage dank der Entscheidungen aus den vorherigen Teilen übersichtlich:</p>
<ul>
<li><strong>Keine Cookies, kein Tracking, keine externen Inhalte.</strong> Das erzwingt die Content-Security-Policy aus Teil 2 sogar technisch.</li>
<li><strong>Keine Zugriffsprotokolle</strong> – Caddy schreibt sie standardmäßig nicht.</li>
<li><strong>Kein Cloudflare-Proxy</strong> – Cloudflare beantwortet nur DNS-Anfragen.</li>
</ul>
<p>Ein Punkt war aber offen: Auch ohne Zugriffsprotokolle schreibt das System Logs, in denen IP-Adressen stehen können – Fehlermeldungen des Webservers, abgewiesene Verbindungen an der Firewall, SSH-Anmeldeversuche. Wie lange werden die aufbewahrt? Bei Debian standardmäßig, bis das Journal eine bestimmte Größe erreicht. Das ist keine Frist, die man in eine Datenschutzerklärung schreiben kann.</p>
<p>Also habe ich eine festgelegt:</p>
<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/journald.conf.d
</span></span><span class="line"><span class="cl">sudo tee /etc/systemd/journald.conf.d/10-retention.conf &gt; /dev/null <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">[Journal]
</span></span></span><span class="line"><span class="cl"><span class="s">MaxRetentionSec=14day
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">sudo systemctl restart systemd-journald
</span></span></code></pre></div><p>Und geprüft, dass es keine klassischen Logdateien daneben gibt, für die das nicht gelten würde:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ls -la /var/log/syslog /var/log/auth.log /var/log/ufw.log 2&gt;<span class="p">&amp;</span><span class="m">1</span>
</span></span><span class="line"><span class="cl">dpkg -l rsyslog 2&gt;/dev/null <span class="p">|</span> grep ^ii
</span></span></code></pre></div><p>Bei Debian 13 gibt es standardmäßig kein <code>rsyslog</code> mehr – alles landet im Journal, und die 14 Tage gelten für alles. Jetzt kann in der Datenschutzerklärung stehen: „wird nach 14 Tagen automatisch gelöscht“. Und es stimmt.</p>
<h3 id="was-drinsteht">Was drinsteht</h3>
<p>Meine Datenschutzerklärung (<code>content/datenschutz.md</code>) ist in der Du-Form geschrieben, passend zum Rest des Blogs, und gliedert sich so:</p>
<ol>
<li><strong>Verantwortlicher</strong> – Name, Anschrift, Kontaktadresse</li>
<li><strong>Hosting</strong> – der VPS-Anbieter mit Anschrift, Rechenzentrum in Deutschland, Vertrag zur Auftragsverarbeitung</li>
<li><strong>Aufruf der Website</strong> – welche Daten der Browser technisch überträgt, keine Zugriffsprotokolle, Systemprotokolle mit 14 Tagen Aufbewahrung, Rechtsgrundlage Art. 6 Abs. 1 lit. f DSGVO</li>
<li><strong>Verschlüsselung</strong> – TLS</li>
<li><strong>Speicherung im Browser</strong> – PaperMod merkt sich die Wahl zwischen hellem und dunklem Design im Local Storage des Browsers (Eintrag <code>pref-theme</code>). Der Wert bleibt auf dem Gerät. Weil er für eine ausdrücklich gewünschte Funktion nötig ist, braucht es keine Einwilligung (§ 25 Abs. 2 Nr. 2 TDDDG).</li>
<li><strong>Kontakt per E-Mail</strong> – was mit Mails passiert und wo der Mailserver steht</li>
<li><strong>Deine Rechte</strong> – Auskunft, Berichtigung, Löschung usw. und die zuständige Datenschutz-Aufsichtsbehörde</li>
<li><strong>Stand</strong></li>
</ol>
<p>Ein Satz am Anfang fasst das Wichtigste zusammen: keine Cookies, kein Tracking, keine Inhalte von Drittanbietern. Wer nur den liest, weiß schon fast alles.</p>
<p>Wichtig: Kommt später etwas dazu – Affiliate-Links, ein Fediverse-Server, ein Kommentarsystem –, muss die Datenschutzerklärung mitwachsen.</p>
<h3 id="ins-menü">Ins Menü</h3>
<p>Beide Seiten müssen von überall leicht erreichbar sein. Bei PaperMod kommen sie einfach ins Hauptmenü, in die <code>hugo.toml</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">menu</span><span class="p">.</span><span class="nx">main</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">identifier</span> <span class="p">=</span> <span class="s2">&#34;impressum&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">name</span> <span class="p">=</span> <span class="s2">&#34;Impressum&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">url</span> <span class="p">=</span> <span class="s2">&#34;/impressum/&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">weight</span> <span class="p">=</span> <span class="mi">90</span>
</span></span><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">menu</span><span class="p">.</span><span class="nx">main</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">identifier</span> <span class="p">=</span> <span class="s2">&#34;datenschutz&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">name</span> <span class="p">=</span> <span class="s2">&#34;Datenschutz&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">url</span> <span class="p">=</span> <span class="s2">&#34;/datenschutz/&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">weight</span> <span class="p">=</span> <span class="mi">91</span>
</span></span></code></pre></div><h2 id="teil-d-ein-favicon--das-k-mit-dem-funken">Teil D: Ein Favicon – das K mit dem Funken</h2>
<p>Zum Schluss das Kleinste, aber Sichtbarste: das Symbol im Browser-Tab. Die Konsole hatte sich schon länger beschwert:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">GET https://example.com/favicon.ico 404 (Not Found)
</span></span><span class="line"><span class="cl">GET https://example.com/favicon-32x32.png 404 (Not Found)
</span></span><span class="line"><span class="cl">GET https://example.com/favicon-16x16.png 404 (Not Found)
</span></span></code></pre></div><h3 id="drei-entwürfe">Drei Entwürfe</h3>
<p>Für das Favicon gab es drei Entwürfe, alle in den Farben der Seite – dunkler Grund, helles Kabel, ein Funke in Bernstein:</p>
<ul>
<li><strong>A:</strong> ein waagerechtes Kabel mit Bruchstelle, blankem Kupfer an den Enden und einem Funken dazwischen</li>
<li><strong>B:</strong> dieselbe Idee, diagonal</li>
<li><strong>C:</strong> ein „K“, dessen Bein gebrochen ist, mit dem Funken in der Lücke</li>
</ul>
<p><img alt="Die drei Favicon-Entwürfe A, B und C in 16, 32, 64 und 128 Pixeln auf hellem und dunklem Hintergrund, rechts jeweils die 16-Pixel-Version vergrößert" loading="lazy" src="/posts/kabelbruch-teil-4-mail-impressum-favicon/favicon-entwuerfe.png"></p>
<p>A und B erzählen die Geschichte des Namens schöner. Aber ein Favicon wird in <strong>16 × 16 Pixeln</strong> angezeigt, und da bleibt von einem Kabel mit Bruchstelle nur ein Strich mit einem orangen Fleck. Das K blieb selbst in dieser Größe eindeutig lesbar. Also wurde es <strong>C</strong>. Der wichtigste Test bei Favicons ist deshalb: die kleinste Größe ansehen, nicht die größte.</p>
<h3 id="die-dateien">Die Dateien</h3>
<p>PaperMod verlinkt im <code>&lt;head&gt;</code> standardmäßig diese Dateien, die einfach in den Ordner <code>static/</code> kommen:</p>
<table>
	<thead>
			<tr>
					<th>Datei</th>
					<th>Wofür</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>favicon.ico</code></td>
					<td>Klassisches Favicon, enthält 16, 32 und 48 Pixel</td>
			</tr>
			<tr>
					<td><code>favicon-16x16.png</code>, <code>favicon-32x32.png</code></td>
					<td>Browser-Tabs</td>
			</tr>
			<tr>
					<td><code>apple-touch-icon.png</code></td>
					<td>180 Pixel, wenn jemand die Seite auf den Homescreen von iPhone oder iPad legt</td>
			</tr>
			<tr>
					<td><code>safari-pinned-tab.svg</code></td>
					<td>einfarbige Version für angeheftete Tabs in Safari</td>
			</tr>
	</tbody>
</table>
<p>Als Grundlage dient eine einzige SVG-Datei. Daraus lassen sich alle Größen mit zwei Python-Bibliotheken erzeugen – CairoSVG zum Rendern und Pillow für die ICO-Datei:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">io</span><span class="o">,</span> <span class="nn">cairosvg</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">PIL</span> <span class="kn">import</span> <span class="n">Image</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">render</span><span class="p">(</span><span class="n">svg</span><span class="p">,</span> <span class="n">size</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">png</span> <span class="o">=</span> <span class="n">cairosvg</span><span class="o">.</span><span class="n">svg2png</span><span class="p">(</span><span class="n">url</span><span class="o">=</span><span class="n">svg</span><span class="p">,</span> <span class="n">output_width</span><span class="o">=</span><span class="n">size</span><span class="p">,</span> <span class="n">output_height</span><span class="o">=</span><span class="n">size</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Image</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">io</span><span class="o">.</span><span class="n">BytesIO</span><span class="p">(</span><span class="n">png</span><span class="p">))</span><span class="o">.</span><span class="n">convert</span><span class="p">(</span><span class="s2">&#34;RGBA&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">render</span><span class="p">(</span><span class="s2">&#34;favicon.svg&#34;</span><span class="p">,</span> <span class="mi">16</span><span class="p">)</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="s2">&#34;static/favicon-16x16.png&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">render</span><span class="p">(</span><span class="s2">&#34;favicon.svg&#34;</span><span class="p">,</span> <span class="mi">32</span><span class="p">)</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="s2">&#34;static/favicon-32x32.png&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">render</span><span class="p">(</span><span class="s2">&#34;favicon-apple.svg&#34;</span><span class="p">,</span> <span class="mi">180</span><span class="p">)</span><span class="o">.</span><span class="n">convert</span><span class="p">(</span><span class="s2">&#34;RGB&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="s2">&#34;static/apple-touch-icon.png&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">render</span><span class="p">(</span><span class="s2">&#34;favicon.svg&#34;</span><span class="p">,</span> <span class="mi">48</span><span class="p">)</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="s2">&#34;static/favicon.ico&#34;</span><span class="p">,</span> <span class="n">sizes</span><span class="o">=</span><span class="p">[(</span><span class="mi">16</span><span class="p">,</span> <span class="mi">16</span><span class="p">),</span> <span class="p">(</span><span class="mi">32</span><span class="p">,</span> <span class="mi">32</span><span class="p">),</span> <span class="p">(</span><span class="mi">48</span><span class="p">,</span> <span class="mi">48</span><span class="p">)])</span>
</span></span></code></pre></div><p>Für das Apple-Icon nehme ich eine Variante <strong>ohne</strong> abgerundete Ecken – iOS rundet selbst ab, sonst gibt es doppelte Rundungen. Und die Safari-Version ist einfarbig schwarz ohne Hintergrund, die Farbe setzt Safari selbst.</p>
<p>Nach dem Push waren die 404-Meldungen weg, und im Tab leuchtet seitdem das K mit dem Funken. Manche Browser halten ein altes Favicon übrigens hartnäckig im Cache – ein privates Fenster hilft beim Testen.</p>
<h3 id="nebenbei-languagecode-ist-veraltet">Nebenbei: <code>languageCode</code> ist veraltet</h3>
<p>Bei jedem Build meldete Hugo zuletzt:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">WARN  deprecated: project config key languageCode was deprecated in Hugo v0.158.0 [...] Use locale instead.
</span></span></code></pre></div><p>Die Lösung ist eine Zeile in der <code>hugo.toml</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="nx">locale</span> <span class="p">=</span> <span class="s2">&#34;de-DE&#34;</span>   <span class="c"># statt: languageCode = &#34;de-de&#34;</span>
</span></span></code></pre></div><p>PaperMod liest die Sprache für RSS und OpenGraph inzwischen ohnehin aus <code>locale</code>. Die Warnung war damit weg, sonst hat sich nichts geändert.</p>
<h2 id="stolperfallen--learnings">Stolperfallen &amp; Learnings</h2>
<ul>
<li><strong>Vor DNS-Änderungen den Ist-Zustand ansehen.</strong> Bei mir zeigte der MX auf einen Server, den es nicht mehr gab.</li>
<li><strong>Einen alten Mailserver erst ablösen, wenn alle Adressen und Mails umgezogen sind</strong> – falls er noch läuft.</li>
<li><strong>SPF auch für Subdomains setzen</strong>, von denen nie Mail kommt: <code>v=spf1 -all</code>.</li>
<li><strong>DKIM-Schlüssel als Text kopieren, nicht abtippen.</strong></li>
<li><strong>Impressum: ladungsfähige Anschrift, kein Postfach.</strong> Mit Werbung oder Affiliate-Links gelten die strengeren DDG-Pflichten.</li>
<li><strong>Die Datenschutzerklärung beschreibt die Technik – nicht umgekehrt.</strong> Erst die Log-Aufbewahrung festlegen, dann die Frist hineinschreiben.</li>
<li><strong>Favicons in 16 Pixeln beurteilen.</strong></li>
</ul>
<h2 id="fazit-der-serie">Fazit der Serie</h2>
<p>Vier Teile, ein Abend – zugegeben, ein langer –, und aus einer Idee ist eine Website geworden, die ich verstehe und gern betreibe:</p>
<ul>
<li>ein kleiner, gehärteter <strong>VPS</strong>, auf dem sich niemand mit Passwort oder als root anmelden kann,</li>
<li><strong>Caddy</strong> mit automatischem HTTPS und Security-Headern, die fremde Inhalte konsequent draußen halten,</li>
<li>eine <strong>Pipeline</strong>, die aus jedem <code>git push</code> in zehn Sekunden eine neue Seite macht – mit einem Runner im isolierten Netz und einem Schlüssel, der nur rsync darf,</li>
<li>eine <strong>Kontaktadresse</strong> mit sauberem SPF, DKIM und DMARC,</li>
<li><strong>Impressum und Datenschutzerklärung</strong>, die zur Technik passen,</li>
<li>und ein <strong>K mit Funken</strong> im Browser-Tab.</li>
</ul>
<p>Alles mit Open-Source-Software, alles selbst betrieben. Und, ganz im Sinne des Mottos: mit einer ganzen Reihe von Fehlern unterwegs, aus denen ich mehr gelernt habe als aus den Schritten, die auf Anhieb geklappt haben.</p>
<p><strong>Was als Nächstes kommt:</strong> Auf meiner Liste stehen eine automatische Prüfung der Content-Security-Policy in der Pipeline, damit mich das nächste Theme-Update nicht wieder mit Mond und Sonne überrascht, und ein Backup-Konzept für den VPS. Und die Idee, die ich in Teil 1 angedeutet habe: ein eigener kleiner Fediverse-Server. Aber das ist eine andere Geschichte.</p>
]]></content:encoded></item><item><title>Wie Kabelbruch entstanden ist – Teil 3: Die Pipeline</title><link>https://kabelbruch.de/posts/kabelbruch-teil-3-pipeline/</link><pubDate>Fri, 30 Oct 2026 23:33:00 +0100</pubDate><guid>https://kabelbruch.de/posts/kabelbruch-teil-3-pipeline/</guid><description>Vom git push zur fertigen Seite: Gitea im Homelab, ein isolierter Runner, ein SSH-Schlüssel, der nur rsync darf – und warum mein Artikel nicht erschien.</description><content:encoded><![CDATA[<p>In <a href="/posts/kabelbruch-teil-1-idee-und-server/">Teil 1</a> habe ich den Server aufgesetzt, in <a href="/posts/kabelbruch-teil-2-caddy-dns-header/">Teil 2</a> liefert er per HTTPS eine Seite aus. Jetzt fehlt noch das Wichtigste für einen Blog: ein bequemer Weg, neue Artikel online zu bringen. Mein Ziel war, dass ich einen Artikel auf dem Laptop schreibe, ihn per <code>git push</code> in meinen eigenen Git-Server schiebe – und der Rest passiert von selbst.</p>
<p>Das klappt inzwischen. Vom Push bis zur fertigen Seite vergehen etwa zehn Sekunden. Wie das aufgebaut ist und warum ich dabei ziemlich paranoid war, zeigt dieser Teil.</p>
<h2 id="der-plan">Der Plan</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Laptop ──git push──▶ Gitea (Homelab) ──▶ Runner (isoliertes LAB-Netz)
</span></span><span class="line"><span class="cl">                                            │  hugo --minify
</span></span><span class="line"><span class="cl">                                            ▼
</span></span><span class="line"><span class="cl">                             rsync über SSH (nur ausgehend)
</span></span><span class="line"><span class="cl">                                            ▼
</span></span><span class="line"><span class="cl">                         VPS: Caddy ──▶ https://example.com
</span></span></code></pre></div><p>Die Bausteine:</p>
<ul>
<li><strong><a href="https://about.gitea.com/" target="_blank" rel="noopener noreferrer">Gitea</a></strong> ist mein selbst gehosteter Git-Server, ein bisschen wie GitHub für zu Hause. Er läuft bei mir schon länger als Docker-Container im Homelab. Gitea bringt mit <strong>Gitea Actions</strong> eine eigene CI mit (Continuous Integration, also automatische Abläufe bei jedem Push), deren Syntax fast identisch zu GitHub Actions ist.</li>
<li>Ein <strong>Runner</strong> führt diese Abläufe aus. Er bekommt bei mir eine eigene kleine VM.</li>
<li><strong><a href="https://gohugo.io/" target="_blank" rel="noopener noreferrer">Hugo</a></strong> baut aus den Markdown-Dateien die fertige Seite.</li>
<li><strong>rsync</strong> über SSH schiebt die Seite zum VPS.</li>
</ul>
<p>Das Schöne daran: Mein Heimnetz öffnet nach außen gar nichts. Der Runner baut zu Hause und verbindet sich <strong>von innen nach außen</strong> zum VPS. Es gibt keine Portfreigabe.</p>
<h2 id="voraussetzungen">Voraussetzungen</h2>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Version / Stand</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Gitea</td>
					<td>28.x, als Docker-Container, Actions aktiv</td>
			</tr>
			<tr>
					<td>Proxmox VE</td>
					<td>9.x (für die Runner-VM)</td>
			</tr>
			<tr>
					<td>Gitea Runner</td>
					<td>Image <code>gitea/runner</code> (der frühere <code>act_runner</code>)</td>
			</tr>
			<tr>
					<td>Hugo</td>
					<td>0.169.0 extended</td>
			</tr>
			<tr>
					<td>Theme</td>
					<td>PaperMod (als Git-Submodule)</td>
			</tr>
			<tr>
					<td>VPS</td>
					<td>Debian 13 mit Caddy aus Teil 2</td>
			</tr>
	</tbody>
</table>
<p>Beispielwerte in diesem Artikel: <code>git.example.com</code> ist mein Gitea, <code>10.0.20.10</code> der Docker-Host, auf dem er läuft, <code>10.0.99.0/24</code> das LAB-Netz, <code>10.0.99.20</code> der Runner und <code>203.0.113.10</code> der VPS.</p>
<h2 id="schritt-1-das-hugo-projekt">Schritt 1: Das Hugo-Projekt</h2>
<p>Hugo installiere ich lokal direkt aus den offiziellen Releases, weil die Pakete der Distributionen oft hinterherhinken:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/.local/bin
</span></span><span class="line"><span class="cl"><span class="nv">URL</span><span class="o">=</span><span class="k">$(</span>curl -s https://api.github.com/repos/gohugoio/hugo/releases/latest <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> grep -o <span class="s1">&#39;https://[^&#34;]*hugo_extended_[0-9.]*_linux-amd64\.tar\.gz&#39;</span> <span class="p">|</span> head -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">curl -L <span class="s2">&#34;</span><span class="nv">$URL</span><span class="s2">&#34;</span> <span class="p">|</span> tar -xz -C ~/.local/bin hugo
</span></span><span class="line"><span class="cl">hugo version
</span></span></code></pre></div><p>Die Versionsnummer merke ich mir, denn genau diese Version bekommt später auch die Pipeline. Dann sehen die Seite lokal und live garantiert gleich aus.</p>
<p>In Gitea lege ich ein leeres, privates Repository an, klone es und erzeuge darin die Seite:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone ssh://git@git.example.com:2222/thomas/kabelbruch.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> kabelbruch
</span></span><span class="line"><span class="cl">hugo new site . --force --format toml
</span></span><span class="line"><span class="cl">git submodule add --depth<span class="o">=</span><span class="m">1</span> https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">cat &gt; .gitignore <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">public/
</span></span></span><span class="line"><span class="cl"><span class="s">resources/_gen/
</span></span></span><span class="line"><span class="cl"><span class="s">.hugo_build.lock
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span></code></pre></div><p>Als Theme nutze ich <a href="https://github.com/adityatelange/hugo-PaperMod" target="_blank" rel="noopener noreferrer">PaperMod</a>: schlicht, schnell, mit Hell-Dunkel-Modus, und es braucht weder Node.js noch externe Schriftarten. Mit <code>hugo server -D</code> sehe ich die Seite lokal unter <code>http://localhost:1313</code> – inklusive Entwürfen.</p>
<h2 id="schritt-2-ein-deploy-schlüssel-der-fast-nichts-darf">Schritt 2: Ein Deploy-Schlüssel, der fast nichts darf</h2>
<p>Die Pipeline braucht einen SSH-Schlüssel, um Dateien auf den VPS zu schieben. Dieser Schlüssel liegt später als Secret in Gitea und muss <strong>ohne Passphrase</strong> funktionieren, sonst kann ihn die Pipeline nicht nutzen. Genau deshalb soll er auf dem Server so wenig dürfen wie möglich.</p>
<h3 id="ein-eigener-benutzer-auf-dem-vps">Ein eigener Benutzer auf dem VPS</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt install -y rsync
</span></span><span class="line"><span class="cl">sudo adduser --system --group --shell /bin/sh --home /home/deploy deploy
</span></span><span class="line"><span class="cl">sudo chown -R deploy:deploy /var/www/kabelbruch
</span></span></code></pre></div><h3 id="der-schlüssel">Der Schlüssel</h3>
<p>Auf dem Laptop, ohne Passphrase (<code>-N &quot;&quot;</code>):</p>
<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 -f ~/.ssh/kabelbruch_deploy -N <span class="s2">&#34;&#34;</span> -C <span class="s2">&#34;gitea-deploy-kabelbruch&#34;</span>
</span></span><span class="line"><span class="cl">cat ~/.ssh/kabelbruch_deploy.pub
</span></span></code></pre></div><h3 id="der-trick-rrsync">Der Trick: <code>rrsync</code></h3>
<p>Auf dem Server trage ich den öffentlichen Schlüssel mit zwei Einschränkungen ein:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /home/deploy/.ssh
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;restrict,command=&#34;/usr/bin/rrsync /var/www/kabelbruch&#34; ssh-ed25519 AAAA… gitea-deploy-kabelbruch&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> sudo tee /home/deploy/.ssh/authorized_keys
</span></span><span class="line"><span class="cl">sudo chown -R deploy:deploy /home/deploy/.ssh
</span></span><span class="line"><span class="cl">sudo chmod <span class="m">700</span> /home/deploy/.ssh
</span></span><span class="line"><span class="cl">sudo chmod <span class="m">600</span> /home/deploy/.ssh/authorized_keys
</span></span></code></pre></div><ul>
<li><strong><code>restrict</code></strong> verbietet alles, was SSH sonst noch kann: Terminal, Port-Forwarding, Agent-Forwarding.</li>
<li><strong><code>command=&quot;/usr/bin/rrsync /var/www/kabelbruch&quot;</code></strong> sorgt dafür, dass mit diesem Schlüssel <strong>ausschließlich</strong> <code>rsync</code> läuft, und zwar nur innerhalb dieses einen Verzeichnisses. <code>rrsync</code> („restricted rsync“) ist bei Debian im <code>rsync</code>-Paket enthalten. Es ist ein Python-Skript, <code>python3</code> muss also installiert sein.</li>
</ul>
<p>Selbst wenn jemand den Schlüssel stiehlt, kann er damit nur meine Website überschreiben. Ärgerlich, aber kein Weg auf den Server.</p>
<h3 id="der-gegentest">Der Gegentest</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ ssh -i ~/.ssh/kabelbruch_deploy deploy@203.0.113.10
</span></span><span class="line"><span class="cl">PTY allocation request failed on channel 0
</span></span><span class="line"><span class="cl">/usr/bin/rrsync error: Not invoked via sshd
</span></span><span class="line"><span class="cl">Connection to 203.0.113.10 closed.
</span></span></code></pre></div><p>Genau so soll es aussehen: kein Terminal, keine Shell. Und der echte Upload:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">rsync -av --delete -e <span class="s2">&#34;ssh -i ~/.ssh/kabelbruch_deploy&#34;</span> /tmp/kb-test/ deploy@203.0.113.10:
</span></span></code></pre></div><p>Wichtig ist der Doppelpunkt am Ende <strong>ohne Pfad</strong>. Das Zielverzeichnis legt <code>rrsync</code> auf dem Server fest.</p>
<h2 id="schritt-3-der-runner--warum-er-in-ein-eigenes-netz-kommt">Schritt 3: Der Runner – warum er in ein eigenes Netz kommt</h2>
<p>Ein Runner führt Code aus dem Repository aus, und dafür bekommt er in der Regel Zugriff auf das Docker-Socket seines Hosts. Das heißt: <strong>Wer den Runner kontrolliert, ist auf dessen Host praktisch root.</strong> Mein Docker-Host im Homelab hat Dienste wie meinen Passwortmanager und meine Fotos. Da gehört so etwas nicht hin.</p>
<p>Deshalb bekommt der Runner eine eigene kleine VM in meinem <strong>LAB-Netz</strong>. Das ist ein VLAN für Testkram, das auf der Firewall vom Rest des Heimnetzes getrennt ist und nur DNS und Internet darf.</p>
<h3 id="die-vm-aus-dem-cloud-image">Die VM aus dem Cloud-Image</h3>
<p>Statt Debian per Hand zu installieren, nehme ich das offizielle <strong>Debian-Cloud-Image</strong> und lasse <strong>cloud-init</strong> Benutzer und SSH-Schlüssel beim ersten Start einrichten. Auf dem Proxmox-Host:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p /var/lib/vz/import <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /var/lib/vz/import
</span></span><span class="line"><span class="cl">wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;ssh-ed25519 AAAA… thomas@laptop&#39;</span> &gt; /root/runner.pub
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">qm create <span class="m">108</span> --name ci-runner --ostype l26 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --cpu host --cores <span class="m">2</span> --memory <span class="m">4096</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --scsihw virtio-scsi-single <span class="se">\
</span></span></span><span class="line"><span class="cl">  --scsi0 ZFS-Pool:0,import-from<span class="o">=</span>/var/lib/vz/import/debian-13-genericcloud-amd64.qcow2,discard<span class="o">=</span>on,iothread<span class="o">=</span><span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --ide2 ZFS-Pool:cloudinit <span class="se">\
</span></span></span><span class="line"><span class="cl">  --boot <span class="nv">order</span><span class="o">=</span>scsi0 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --net0 virtio,bridge<span class="o">=</span>vmbr0,tag<span class="o">=</span>99,firewall<span class="o">=</span><span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --serial0 socket --vga serial0 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --agent <span class="nv">enabled</span><span class="o">=</span><span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --ciuser thomas --sshkeys /root/runner.pub <span class="se">\
</span></span></span><span class="line"><span class="cl">  --ipconfig0 <span class="nv">ip</span><span class="o">=</span>dhcp --ciupgrade <span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --onboot <span class="m">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">qm disk resize <span class="m">108</span> scsi0 32G
</span></span><span class="line"><span class="cl">qm config <span class="m">108</span> <span class="p">|</span> grep net0
</span></span></code></pre></div><p><code>ZFS-Pool</code> ist der Name meines Speichers und <code>tag=99</code> steht hier für die VLAN-ID des LAB-Netzes – beides an die eigene Umgebung anpassen. Aus der letzten Zeile notiere ich mir die <strong>MAC-Adresse</strong>, denn die VM soll per DHCP-Reservierung immer dieselbe IP bekommen.</p>
<p><strong>Noch nicht starten</strong> – erst kommt die Firewall.</p>
<h3 id="gescheitert-teil-1-qcow21">Gescheitert, Teil 1: <code>.qcow2.1</code></h3>
<p><code>wget</code> meldete beim Download: <code>Saving to: 'debian-13-genericcloud-amd64.qcow2.1'</code>. Eine Datei mit demselben Namen lag schon im Verzeichnis, von einem früheren Versuch. Hätte ich das übersehen, hätte <code>qm create</code> das alte Image importiert. Also erst aufräumen:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mv -f debian-13-genericcloud-amd64.qcow2.1 debian-13-genericcloud-amd64.qcow2
</span></span></code></pre></div><h3 id="eine-einzige-ausnahme-in-der-firewall">Eine einzige Ausnahme in der Firewall</h3>
<p>Der Runner muss genau zwei Dinge können: ins Internet (Container-Images, GitHub, SSH zum VPS) und <strong>mein Gitea erreichen</strong>. Das Internet ist im LAB-Netz ohnehin erlaubt. Für Gitea braucht es eine einzige, möglichst schmale Ausnahme: <strong>nur diese eine IP, nur zum Gitea-Host, nur HTTPS.</strong></p>
<p>Auf meiner FortiGate sieht das so aus (Objektnamen an die eigene Konfiguration anpassen):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">config firewall address
</span></span><span class="line"><span class="cl">    edit &#34;H_CI_RUNNER&#34;
</span></span><span class="line"><span class="cl">        set subnet 10.0.99.20 255.255.255.255
</span></span><span class="line"><span class="cl">    next
</span></span><span class="line"><span class="cl">end
</span></span><span class="line"><span class="cl">config firewall policy
</span></span><span class="line"><span class="cl">    edit 0
</span></span><span class="line"><span class="cl">        set name &#34;RUNNER-to-GITEA&#34;
</span></span><span class="line"><span class="cl">        set srcintf &#34;VL99_LAB&#34;
</span></span><span class="line"><span class="cl">        set dstintf &#34;VL20_SERVER&#34;
</span></span><span class="line"><span class="cl">        set srcaddr &#34;H_CI_RUNNER&#34;
</span></span><span class="line"><span class="cl">        set dstaddr &#34;H_DOCKER_HOST&#34;
</span></span><span class="line"><span class="cl">        set action accept
</span></span><span class="line"><span class="cl">        set schedule &#34;always&#34;
</span></span><span class="line"><span class="cl">        set service &#34;HTTPS&#34;
</span></span><span class="line"><span class="cl">        set utm-status enable
</span></span><span class="line"><span class="cl">        set ips-sensor &#34;default&#34;
</span></span><span class="line"><span class="cl">        set logtraffic all
</span></span><span class="line"><span class="cl">    next
</span></span><span class="line"><span class="cl">end
</span></span></code></pre></div><p>Die Warnung „The no-inspection profile doesn&rsquo;t perform SSL inspection“ beim Speichern ist hier gewollt. Der IPS-Sensor schaut trotzdem mit.</p>
<p>Den Code holt der Runner übrigens per <strong>HTTPS</strong> mit einem Token, das Gitea für jeden Lauf automatisch erzeugt. Einen SSH-Zugang zu Gitea braucht er nicht.</p>
<p>Eine ehrliche Einschränkung: Gitea läuft bei mir hinter einem Reverse Proxy, der auf derselben IP noch andere Dienste ausliefert. Rein vom Netz her könnte der Runner also auch deren Login-Seiten erreichen – Zugangsdaten hat er natürlich keine. Wer es ganz dicht will, legt im Reverse Proxy zusätzlich eine Zugriffsliste an, die die Runner-IP nur für Gitea zulässt.</p>
<h3 id="vm-starten-und-einrichten">VM starten und einrichten</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qm start <span class="m">108</span>
</span></span></code></pre></div><p>Nach etwa einer Minute ist die VM erreichbar. Vom Laptop aus ein kurzer Test, ob das Netz so funktioniert wie gedacht:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ssh thomas@10.0.99.20
</span></span><span class="line"><span class="cl">getent hosts git.example.com                    <span class="c1"># DNS</span>
</span></span><span class="line"><span class="cl">curl -sI https://git.example.com <span class="p">|</span> head -n <span class="m">1</span>     <span class="c1"># Gitea: Firewall-Ausnahme</span>
</span></span><span class="line"><span class="cl">curl -sI https://github.com <span class="p">|</span> head -n <span class="m">1</span>          <span class="c1"># Internet</span>
</span></span></code></pre></div><p>Dann Updates, Gast-Agent, automatische Sicherheitsupdates und Docker aus dem offiziellen Repository:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt update <span class="o">&amp;&amp;</span> sudo apt full-upgrade -y
</span></span><span class="line"><span class="cl">sudo apt install -y qemu-guest-agent unattended-upgrades curl ca-certificates
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now qemu-guest-agent
</span></span><span class="line"><span class="cl">sudo dpkg-reconfigure -plow unattended-upgrades
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">sudo install -m <span class="m">0755</span> -d /etc/apt/keyrings
</span></span><span class="line"><span class="cl">sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
</span></span><span class="line"><span class="cl">sudo chmod a+r /etc/apt/keyrings/docker.asc
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;deb [arch=</span><span class="k">$(</span>dpkg --print-architecture<span class="k">)</span><span class="s2"> signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian </span><span class="k">$(</span>. /etc/os-release <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$VERSION_CODENAME</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> stable&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> sudo tee /etc/apt/sources.list.d/docker.list
</span></span><span class="line"><span class="cl">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
</span></span><span class="line"><span class="cl">sudo docker run --rm hello-world
</span></span></code></pre></div><h2 id="schritt-4-den-runner-registrieren">Schritt 4: Den Runner registrieren</h2>
<p>Kleine Begriffsverwirrung vorweg: Der Runner hieß früher <code>act_runner</code> und wurde 2026 in <strong>Gitea Runner</strong> umbenannt. Das alte Docker-Image <code>gitea/act_runner</code> ist als veraltet markiert, aktuell ist <code>gitea/runner</code>.</p>
<p>Den Runner registriere ich <strong>nur für dieses eine Repository</strong>, nicht für die ganze Gitea-Instanz. Dann führt er auch nur Jobs aus diesem Repository aus. Den Registrierungs-Token gibt es in Gitea unter <em>Repository → Einstellungen → Actions → Runners → „Neuen Runner erstellen“</em>.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /opt/runner <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/runner
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">sudo tee compose.yaml &gt; /dev/null <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">services:
</span></span></span><span class="line"><span class="cl"><span class="s">  runner:
</span></span></span><span class="line"><span class="cl"><span class="s">    image: gitea/runner:latest
</span></span></span><span class="line"><span class="cl"><span class="s">    container_name: gitea-runner
</span></span></span><span class="line"><span class="cl"><span class="s">    restart: unless-stopped
</span></span></span><span class="line"><span class="cl"><span class="s">    environment:
</span></span></span><span class="line"><span class="cl"><span class="s">      - GITEA_INSTANCE_URL=https://git.example.com
</span></span></span><span class="line"><span class="cl"><span class="s">      - GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
</span></span></span><span class="line"><span class="cl"><span class="s">      - GITEA_RUNNER_NAME=ci-runner
</span></span></span><span class="line"><span class="cl"><span class="s">      - GITEA_RUNNER_LABELS=ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
</span></span></span><span class="line"><span class="cl"><span class="s">    volumes:
</span></span></span><span class="line"><span class="cl"><span class="s">      - ./data:/data
</span></span></span><span class="line"><span class="cl"><span class="s">      - /var/run/docker.sock:/var/run/docker.sock
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;RUNNER_TOKEN=&#39;&lt;TOKEN_AUS_GITEA&gt;&#39;&#34;</span> <span class="p">|</span> sudo tee .env &gt; /dev/null
</span></span><span class="line"><span class="cl">sudo chmod <span class="m">600</span> .env
</span></span><span class="line"><span class="cl">sudo docker compose up -d
</span></span><span class="line"><span class="cl">sudo docker logs -f gitea-runner
</span></span></code></pre></div><p>Die einfachen Anführungszeichen um den Token in der <code>.env</code> sind Absicht: Sie schützen Sonderzeichen wie <code>$</code>, die Docker Compose sonst als Variable interpretieren würde.</p>
<p>Im Log steht dann zuerst <code>.runner is missing or not a regular file</code> – das ist beim ersten Start normal, die Registrierungsdatei gibt es ja noch nicht – und danach <code>Runner registered successfully</code>.</p>
<h3 id="den-token-wieder-loswerden">Den Token wieder loswerden</h3>
<p>Der Token wird nur für die Registrierung gebraucht. Danach liegt alles Nötige in <code>data/.runner</code>. Also weg damit:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo rm .env
</span></span><span class="line"><span class="cl">sudo sed -i <span class="s1">&#39;/GITEA_RUNNER_REGISTRATION_TOKEN/d&#39;</span> compose.yaml
</span></span><span class="line"><span class="cl">sudo docker compose up -d
</span></span><span class="line"><span class="cl">sudo docker logs gitea-runner
</span></span></code></pre></div><p>Lässt man die Zeile in der <code>compose.yaml</code> stehen, warnt Compose bei jedem Start <code>The &quot;RUNNER_TOKEN&quot; variable is not set</code>. Harmlos, aber unnötig. Wichtig ist, dass beim Neustart <strong>keine</strong> neue Registrierung im Log auftaucht, sondern nur <code>declare successfully</code>.</p>
<p>In Gitea steht der Runner jetzt mit dem Status <strong>Idle</strong> unter <em>Einstellungen → Actions → Runners</em>.</p>
<h3 id="ein-kurzer-funktionstest">Ein kurzer Funktionstest</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .gitea/workflows/runner-test.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Runner-Test</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="l">workflow_dispatch</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">test</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">head -n 5 hugo.toml</span><span class="w">
</span></span></span></code></pre></div><p><code>workflow_dispatch</code> heißt: Der Workflow startet nur auf Knopfdruck (<em>Actions → Runner-Test → Workflow ausführen</em>). Beim ersten Lauf lädt der Runner einmalig das Ubuntu-Image, danach war der Test nach 20 Sekunden grün.</p>
<h2 id="schritt-5-secrets-und-der-host-schlüssel">Schritt 5: Secrets und der Host-Schlüssel</h2>
<p>Die Pipeline braucht zwei Dinge aus Gitea:</p>
<p><strong>1. Den privaten Deploy-Schlüssel als Secret.</strong> <em>Einstellungen → Actions → Secrets</em>, Name <code>DEPLOY_KEY</code>, Wert: der komplette Inhalt von <code>~/.ssh/kabelbruch_deploy</code> inklusive der <code>BEGIN</code>- und <code>END</code>-Zeilen.</p>
<p><strong>2. Den Host-Schlüssel des VPS als Variable.</strong> Die Pipeline soll sich nur mit <strong>meinem</strong> Server verbinden und keinen fremden akzeptieren. Der bequeme Weg wäre <code>StrictHostKeyChecking=no</code> – genau den will ich nicht. Stattdessen hole ich den Host-Schlüssel einmal ab und prüfe ihn gegen:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># auf dem Laptop</span>
</span></span><span class="line"><span class="cl">ssh-keyscan -t ed25519 203.0.113.10 2&gt;/dev/null <span class="p">|</span> tee /tmp/kb_known_hosts
</span></span><span class="line"><span class="cl">ssh-keygen -lf /tmp/kb_known_hosts
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># auf dem VPS</span>
</span></span><span class="line"><span class="cl">ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
</span></span></code></pre></div><p>Nur wenn beide <code>SHA256:…</code>-Fingerprints übereinstimmen, kommt die Zeile aus <code>/tmp/kb_known_hosts</code> als <strong>Variable</strong> <code>KNOWN_HOSTS</code> in Gitea. Ein Secret ist dafür nicht nötig, der Host-Schlüssel ist öffentlich.</p>
<p>Kleiner Stolperstein im Eingabefeld: Die Zeile ist so lang, dass Gitea sie optisch umbricht. Ob das nur die Anzeige ist oder ein echter Zeilenumbruch, der den Eintrag kaputtmachen würde, sieht man nicht. Mein Trick: Cursor hinter <code>ssh-ed25519</code>, mit Entf alles bis zum <code>AAAA…</code> heranziehen, ein Leerzeichen setzen. Dann ist es garantiert eine Zeile.</p>
<h2 id="schritt-6-der-deploy-workflow">Schritt 6: Der Deploy-Workflow</h2>
<p>Das Herzstück, <code>.gitea/workflows/deploy.yml</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build &amp; Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.169.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checkout (inkl. Theme)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">submodules</span><span class="p">:</span><span class="w"> </span><span class="l">recursive</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Werkzeuge installieren</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          apt-get update -qq
</span></span></span><span class="line"><span class="cl"><span class="sd">          apt-get install -y -qq rsync openssh-client
</span></span></span><span class="line"><span class="cl"><span class="sd">          curl -fsSL &#34;https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz&#34; \
</span></span></span><span class="line"><span class="cl"><span class="sd">            | tar -xz -C /usr/local/bin hugo
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo version</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Seite bauen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">hugo --minify --gc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">SSH vorbereiten</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">DEPLOY_KEY</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.DEPLOY_KEY }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">KNOWN_HOSTS</span><span class="p">:</span><span class="w"> </span><span class="l">${{ vars.KNOWN_HOSTS }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          install -m 700 -d ~/.ssh
</span></span></span><span class="line"><span class="cl"><span class="sd">          printf &#39;%s\n&#39; &#34;$DEPLOY_KEY&#34; &gt; ~/.ssh/deploy_key
</span></span></span><span class="line"><span class="cl"><span class="sd">          chmod 600 ~/.ssh/deploy_key
</span></span></span><span class="line"><span class="cl"><span class="sd">          printf &#39;%s\n&#39; &#34;$KNOWN_HOSTS&#34; &gt; ~/.ssh/known_hosts
</span></span></span><span class="line"><span class="cl"><span class="sd">          ssh-keygen -lf ~/.ssh/known_hosts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          rsync -rltcz --delete --chmod=D755,F644 \
</span></span></span><span class="line"><span class="cl"><span class="sd">            -e &#34;ssh -i $HOME/.ssh/deploy_key -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts&#34; \
</span></span></span><span class="line"><span class="cl"><span class="sd">            public/ deploy@203.0.113.10:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Key entfernen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">always()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">rm -f ~/.ssh/deploy_key</span><span class="w">
</span></span></span></code></pre></div><p>Was die Details bewirken:</p>
<ul>
<li><strong><code>on: push</code> auf <code>main</code></strong>: Jeder Push veröffentlicht. Mit <code>workflow_dispatch</code> geht es zusätzlich per Knopfdruck.</li>
<li><strong><code>concurrency</code></strong>: Zwei schnelle Pushes hintereinander deployen nacheinander, nicht gleichzeitig.</li>
<li><strong><code>HUGO_VERSION</code> fest gepinnt</strong>: Dieselbe Version wie auf dem Laptop – keine Überraschungen nach einem Hugo-Update.</li>
<li><strong>Secrets über <code>env:</code> statt direkt im Skript</strong>: Gitea setzt den Wert nicht in den Befehlstext ein, sondern übergibt ihn als Umgebungsvariable. Sonderzeichen können so den Befehl nicht verbiegen.</li>
<li><strong><code>ssh-keygen -lf</code></strong> schreibt den Fingerprint ins Log. So sehe ich bei jedem Lauf, dass der richtige Host-Schlüssel benutzt wird.</li>
<li><strong><code>rsync -rltcz</code></strong>: <code>-c</code> vergleicht Prüfsummen statt Zeitstempel. Hugo erzeugt bei jedem Build neue Dateien, mit <code>-c</code> werden trotzdem nur wirklich geänderte übertragen. <code>--delete</code> räumt alte Dateien auf dem Server weg, <code>--chmod</code> setzt saubere Rechte.</li>
<li><strong><code>if: always()</code></strong>: Der private Schlüssel wird auch dann gelöscht, wenn ein Schritt vorher fehlschlägt.</li>
</ul>
<h3 id="gescheitert-teil-2-der-verschwundene-ordner">Gescheitert, Teil 2: Der verschwundene Ordner</h3>
<p>Den Test-Workflow wollte ich im selben Zug loswerden und habe zuerst <code>git rm .gitea/workflows/runner-test.yml</code> ausgeführt und danach die neue <code>deploy.yml</code> angelegt. Ergebnis:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">bash: .gitea/workflows/deploy.yml: No such file or directory
</span></span></code></pre></div><p>Git hatte mit der einzigen Datei auch den nun leeren Ordner entfernt. Committet und gepusht wurde nur das Löschen. Ein <code>mkdir -p .gitea/workflows</code> davor, und es lief.</p>
<h2 id="gescheitert-teil-3-der-artikel-der-nicht-erschien">Gescheitert, Teil 3: Der Artikel, der nicht erschien</h2>
<p>Als Teil 1 dieser Serie fertig war, habe ich ihn committet, gepusht, die Pipeline lief grün durch – und die Seite lieferte <code>404</code>.</p>
<p>Erster Verdacht: Timing. Der <code>curl</code> kam vielleicht, bevor das Deployment fertig war. War es aber nicht. Die Pipeline hatte sauber gebaut, denn andere Änderungen aus demselben Commit waren live.</p>
<p>Die Ursache stand in der ersten Zeile, die man immer übersieht: Der Artikel war im Commit noch auf <strong><code>draft: true</code></strong>. Ich hatte ihn zwar auf veröffentlicht umgestellt, im Projekt war aber eine ältere Fassung der Datei gelandet. Und Hugo macht genau, was es soll: <strong>Entwürfe werden beim Build einfach übersprungen</strong> – ohne Warnung, ohne Fehler, die Pipeline bleibt grün.</p>
<p>Seitdem prüfe ich vor dem Veröffentlichen, was wirklich im Commit steht, und nicht, was ich glaube, gespeichert zu haben:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git show HEAD:content/posts/mein-artikel.md <span class="p">|</span> grep <span class="s1">&#39;^draft&#39;</span>
</span></span></code></pre></div><h2 id="stolperfallen--learnings">Stolperfallen &amp; Learnings</h2>
<ul>
<li><strong>Den Runner isolieren.</strong> Er führt Code aus und hat über das Docker-Socket Root-Rechte auf seinem Host.</li>
<li><strong>Eine Firewall-Ausnahme, so schmal wie möglich:</strong> eine Quell-IP, ein Ziel, ein Dienst.</li>
<li><strong>Deploy-Schlüssel mit <code>restrict</code> und <code>rrsync</code> einschränken.</strong> Dann ist ein gestohlener Schlüssel kein Weg auf den Server.</li>
<li><strong>Host-Schlüssel prüfen statt <code>StrictHostKeyChecking=no</code>.</strong> Einmal den Fingerprint vergleichen, dann ist Ruhe.</li>
<li><strong>Registrierungs-Token nach dem ersten Start entfernen.</strong></li>
<li><strong><code>wget</code> hängt <code>.1</code> an</strong>, wenn die Datei schon existiert. Vor dem Import prüfen.</li>
<li><strong><code>git rm</code> löscht leere Ordner mit.</strong></li>
<li><strong>Eine grüne Pipeline heißt nicht, dass der Artikel online ist.</strong> Hugo überspringt Entwürfe stillschweigend.</li>
</ul>
<h2 id="fazit--ausblick">Fazit &amp; Ausblick</h2>
<p>Die Pipeline ist der Teil, der mir am meisten Spaß macht: Ich schreibe, pushe – und zehn Sekunden später ist der Artikel online. Dabei hat jeder Baustein nur genau die Rechte, die er braucht. Der Runner steht in einem isolierten Netz mit einer einzigen Ausnahme. Der Deploy-Schlüssel kann nur rsync in ein Verzeichnis. Und die Pipeline spricht nur mit einem Server, dessen Schlüssel sie kennt.</p>
<p><strong>Was als Nächstes kommt:</strong> In Teil 4 geht es um die Dinge, die eine Website erst „fertig“ machen: eine funktionierende Kontaktadresse (inklusive eines Mailservers, der nur noch im DNS existierte), SPF, DKIM und DMARC, Impressum und Datenschutzerklärung – und ein Favicon.</p>
]]></content:encoded></item><item><title>Wie Kabelbruch entstanden ist – Teil 2: Caddy, DNS und Security-Header</title><link>https://kabelbruch.de/posts/kabelbruch-teil-2-caddy-dns-header/</link><pubDate>Fri, 30 Oct 2026 23:17:00 +0100</pubDate><guid>https://kabelbruch.de/posts/kabelbruch-teil-2-caddy-dns-header/</guid><description>DNS ohne Cloudflare-Proxy, Caddy mit automatischem TLS, strenge Security-Header – und warum auf meiner Seite Mond und Sonne gleichzeitig leuchteten.</description><content:encoded><![CDATA[<p>In <a href="/posts/kabelbruch-teil-1-idee-und-server/">Teil 1</a> habe ich einen kleinen VPS gemietet und abgesichert. Jetzt soll er endlich etwas tun: eine Website ausliefern. In diesem Teil geht es um DNS, den Webserver Caddy, automatische TLS-Zertifikate und Security-Header. Und um eine Content-Security-Policy, die mir als Erstes das Theme meiner Seite zerlegt hat.</p>
<h2 id="voraussetzungen">Voraussetzungen</h2>
<ul>
<li>Der gehärtete VPS aus Teil 1 mit <strong>Debian 13</strong>, Firewall <code>ufw</code> (Ports 22, 80 und 443 offen)</li>
<li>Eine eigene Domain, deren DNS bei einem Anbieter liegt, bei dem man A- und AAAA-Einträge setzen kann (bei mir: Cloudflare)</li>
<li>Später für den CSP-Teil: <strong>Hugo 0.169.0</strong> mit dem Theme <strong>PaperMod</strong></li>
</ul>
<p>In den Beispielen steht <code>example.com</code> für meine Domain, <code>203.0.113.10</code> für die IPv4- und <code>2001:db8::10</code> für die IPv6-Adresse des Servers.</p>
<h2 id="schritt-1-dns--und-warum-die-wolke-grau-bleibt">Schritt 1: DNS – und warum die Wolke grau bleibt</h2>
<p>Meine Domain liegt bei Cloudflare. Cloudflare kann Einträge entweder nur als DNS beantworten (graue Wolke, „DNS only“) oder den ganzen Verkehr als Proxy durch die eigenen Server leiten (orange Wolke). Der Proxy ist standardmäßig an. Ich habe ihn bewusst <strong>ausgeschaltet</strong>:</p>
<ul>
<li><strong>Datenschutz:</strong> Mit Proxy läuft jeder Seitenaufruf inklusive IP-Adresse über ein US-Unternehmen, das den TLS-Verkehr zudem entschlüsselt. Das müsste ich in der Datenschutzerklärung erklären – und genau solche Abhängigkeiten wollte ich vermeiden.</li>
<li><strong>Durchschaubarkeit:</strong> Ohne Proxy holt sich der Webserver seine Zertifikate selbst, und ich sehe direkt, was zwischen Browser und Server passiert.</li>
<li><strong>Zukunft:</strong> Ich plane einen eigenen Fediverse-Server. Fediverse-Server sprechen direkt miteinander, und Bot-Schutz im Proxy blockt solche Anfragen gern.</li>
</ul>
<p>Cloudflare beantwortet bei mir also nur noch DNS-Anfragen und sieht keine Besucher.</p>
<h3 id="ipv6-adresse-herausfinden">IPv6-Adresse herausfinden</h3>
<p>Für den AAAA-Eintrag brauche ich die IPv6-Adresse des Servers:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ip -6 addr show scope global
</span></span></code></pre></div><p>Interessant: Die Adresse, die netcup automatisch vergibt, wird aus der MAC-Adresse der virtuellen Netzwerkkarte gebildet (man erkennt das am <code>ff:fe</code> in der Mitte). Sie bleibt stabil, solange die Netzwerkkarte nicht neu angelegt wird. Für den Anfang reicht das völlig.</p>
<h3 id="die-einträge">Die Einträge</h3>
<table>
	<thead>
			<tr>
					<th>Typ</th>
					<th>Name</th>
					<th>Ziel</th>
					<th>Proxy</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>A</td>
					<td><code>@</code></td>
					<td><code>203.0.113.10</code></td>
					<td>DNS only</td>
			</tr>
			<tr>
					<td>AAAA</td>
					<td><code>@</code></td>
					<td><code>2001:db8::10</code></td>
					<td>DNS only</td>
			</tr>
			<tr>
					<td>A</td>
					<td><code>www</code></td>
					<td><code>203.0.113.10</code></td>
					<td>DNS only</td>
			</tr>
			<tr>
					<td>AAAA</td>
					<td><code>www</code></td>
					<td><code>2001:db8::10</code></td>
					<td>DNS only</td>
			</tr>
	</tbody>
</table>
<p>Prüfen, ob alles angekommen ist – direkt beim öffentlichen Resolver gefragt:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig +short example.com A @1.1.1.1
</span></span><span class="line"><span class="cl">dig +short example.com AAAA @1.1.1.1
</span></span><span class="line"><span class="cl">dig +short www.example.com A @1.1.1.1
</span></span></code></pre></div><p>Kommen hier Adressen von Cloudflare zurück statt der eigenen, ist der Proxy noch an.</p>
<h2 id="schritt-2-caddy-installieren">Schritt 2: Caddy installieren</h2>
<p>Als Webserver nutze ich <a href="https://caddyserver.com/" target="_blank" rel="noopener noreferrer">Caddy</a>. Der große Vorteil: Caddy besorgt und erneuert TLS-Zertifikate von Let&rsquo;s Encrypt <strong>komplett automatisch</strong>, die Konfiguration ist kurz und lesbar, und Caddy ist – natürlich – Open Source.</p>
<p>Debian hat zwar ein eigenes Caddy-Paket, das ist aber oft deutlich älter. Ich nehme deshalb das offizielle Repository des Projekts, dann kommen Updates ganz normal über <code>apt</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
</span></span><span class="line"><span class="cl">curl -1sLf <span class="s1">&#39;https://dl.cloudsmith.io/public/caddy/stable/gpg.key&#39;</span> <span class="p">|</span> sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
</span></span><span class="line"><span class="cl">curl -1sLf <span class="s1">&#39;https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt&#39;</span> <span class="p">|</span> sudo tee /etc/apt/sources.list.d/caddy-stable.list
</span></span><span class="line"><span class="cl">sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list
</span></span><span class="line"><span class="cl">sudo apt update <span class="o">&amp;&amp;</span> sudo apt install -y caddy
</span></span><span class="line"><span class="cl">caddy version
</span></span></code></pre></div><p>Für den ersten Test lege ich ein Webverzeichnis mit einer Mini-Seite an:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /var/www/kabelbruch
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;&lt;!doctype html&gt;&lt;html lang=&#34;de&#34;&gt;&lt;meta charset=&#34;utf-8&#34;&gt;&lt;title&gt;Kabelbruch&lt;/title&gt;&lt;h1&gt;Kabelbruch – hier entsteht was.&lt;/h1&gt;&lt;/html&gt;&#39;</span> <span class="p">|</span> sudo tee /var/www/kabelbruch/index.html
</span></span></code></pre></div><h2 id="schritt-3-das-caddyfile">Schritt 3: Das Caddyfile</h2>
<p>Die komplette Konfiguration steht in <code>/etc/caddy/Caddyfile</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddyfile" data-lang="caddyfile"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">email</span> <span class="s">admin@example.com</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gh">www.example.com</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">header</span> <span class="s">-Server</span>
</span></span><span class="line"><span class="cl">    <span class="k">redir</span> <span class="s">https://example.com</span><span class="se">{uri}</span> <span class="s">permanent</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gh">example.com</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">root</span> <span class="nd">*</span> <span class="s">/var/www/kabelbruch</span>
</span></span><span class="line"><span class="cl">    <span class="k">encode</span> <span class="s">zstd</span> <span class="s">gzip</span>
</span></span><span class="line"><span class="cl">    <span class="k">file_server</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">header</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">Strict-Transport-Security</span> <span class="s2">&#34;max-age=31536000&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">X-Content-Type-Options</span> <span class="s2">&#34;nosniff&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">Referrer-Policy</span> <span class="s2">&#34;strict-origin-when-cross-origin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">Permissions-Policy</span> <span class="s2">&#34;camera=(), microphone=(), geolocation=()&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">Content-Security-Policy</span> <span class="s2">&#34;default-src &#39;self&#39;; img-src &#39;self&#39; data:; object-src &#39;none&#39;; base-uri &#39;self&#39;; frame-ancestors &#39;none&#39;&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">-Server</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Was hier passiert:</p>
<ul>
<li><strong><code>email</code></strong> ist die Adresse für Let&rsquo;s Encrypt. Dorthin kommt nur Post, wenn mit den Zertifikaten etwas nicht stimmt.</li>
<li><strong><code>www</code></strong> leitet dauerhaft (301) auf die Domain ohne <code>www</code> um. So gibt es die Seite nur unter einer Adresse.</li>
<li><strong><code>encode zstd gzip</code></strong> komprimiert die Antworten.</li>
<li><strong><code>file_server</code></strong> liefert die Dateien aus <code>/var/www/kabelbruch</code> aus. Mehr braucht eine statische Seite nicht.</li>
</ul>
<p>Und die Header im Einzelnen:</p>
<table>
	<thead>
			<tr>
					<th>Header</th>
					<th>Wirkung</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>Strict-Transport-Security</code> (HSTS)</td>
					<td>Browser verbinden sich ein Jahr lang nur noch per HTTPS. Bewusst <strong>ohne</strong> <code>preload</code> – das lässt sich kaum rückgängig machen, also erst mal ohne.</td>
			</tr>
			<tr>
					<td><code>X-Content-Type-Options</code></td>
					<td>Der Browser rät keine Dateitypen, sondern nimmt, was der Server sagt.</td>
			</tr>
			<tr>
					<td><code>Referrer-Policy</code></td>
					<td>Andere Seiten erfahren beim Klick auf einen Link nur meine Domain, nicht die genaue Unterseite.</td>
			</tr>
			<tr>
					<td><code>Permissions-Policy</code></td>
					<td>Kamera, Mikrofon und Standort sind für diese Seite grundsätzlich gesperrt.</td>
			</tr>
			<tr>
					<td><code>Content-Security-Policy</code> (CSP)</td>
					<td>Die Seite darf nur Inhalte von sich selbst laden. Keine fremden Skripte, Schriftarten oder Tracker – und wenn das Theme doch etwas Externes nachladen will, fällt es sofort auf.</td>
			</tr>
			<tr>
					<td><code>-Server</code></td>
					<td>Der Server verrät nicht, welche Software läuft.</td>
			</tr>
	</tbody>
</table>
<p>Noch ein angenehmer Standard: <strong>Caddy schreibt von sich aus keine Zugriffsprotokolle.</strong> Für den Datenschutz ist das genau richtig.</p>
<p>Bevor eine neue Konfiguration aktiv wird, lasse ich sie immer erst prüfen:</p>
<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></span><span class="line"><span class="cl">sudo systemctl reload caddy
</span></span></code></pre></div><h3 id="gescheitert-ein-link-im-caddyfile">Gescheitert: ein Link im Caddyfile</h3>
<p>Mein erstes Caddyfile hatte ich aus einem Chatfenster kopiert. Beim Nachsehen stand in Zeile 5 plötzlich so etwas:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">[www.example.com](https://www.example.com) {
</span></span></code></pre></div><p>Der Chat hatte den Hostnamen automatisch in einen Markdown-Link verwandelt – und genau so war er in der Datei gelandet. <code>caddy validate</code> hätte das zum Glück abgefangen, bevor etwas kaputtgeht.</p>
<p>Der eigentliche Spaß kam beim Reparieren: Jeder <code>sed</code>-Befehl, in dem der Hostname vorkam, wurde beim Kopieren <strong>wieder</strong> zu einem Link. Ich habe die Klammer also mehrfach „repariert“, indem ich sie neu hineinkopiert habe. Gelöst hat es ein Befehl, der den Hostnamen gar nicht erst enthält, sondern nur die überflüssigen Zeichen in Zeile 5 entfernt:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo sed -i -e <span class="s1">&#39;5s/^\[//&#39;</span> -e <span class="s1">&#39;5s/](.*)//&#39;</span> /etc/caddy/Caddyfile
</span></span></code></pre></div><p>Und dann die nächste Falle: Als ich die Ausgabe von <code>grep</code> zur Kontrolle zurück in den Chat kopiert habe, sah sie <strong>wieder</strong> kaputt aus – weil der Chat den Hostnamen beim Anzeigen erneut verlinkt hat. Die Datei war längst in Ordnung. Ein Screenshot vom Terminal hat das am Ende geklärt. Ein Test, der den Hostnamen nicht enthält, hätte es auch getan:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">grep -c <span class="s1">&#39;\[&#39;</span> /etc/caddy/Caddyfile    <span class="c1"># 0 = keine eckigen Klammern mehr</span>
</span></span></code></pre></div><p><strong>Learning:</strong> Hostnamen in Befehlen, die man aus Chats oder Webseiten kopiert, immer kritisch anschauen. Und im Zweifel einen Screenshot vom Terminal machen statt Text zu kopieren.</p>
<h2 id="schritt-4-testen">Schritt 4: Testen</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -I https://example.com
</span></span><span class="line"><span class="cl">curl -I https://www.example.com
</span></span></code></pre></div><p>Die erste Anfrage liefert <code>HTTP/2 200</code> mit allen Headern, die zweite eine <code>301</code>-Weiterleitung. Ein gültiges Zertifikat gab es nach wenigen Sekunden, ohne dass ich irgendetwas dafür tun musste.</p>
<p>Dabei sind mir noch zwei Kleinigkeiten aufgefallen:</p>
<p><strong>1. <code>server: Caddy</code> bei der Weiterleitung.</strong> Die Header-Regeln galten anfangs nur für den Hauptblock, nicht für den <code>www</code>-Block. Deshalb steht oben im Caddyfile auch dort ein <code>header -Server</code>.</p>
<p><strong>2. HTTP/3 braucht UDP.</strong> In den Antworten steht <code>alt-svc: h3=&quot;:443&quot;</code>. Caddy bietet also HTTP/3 an, und das läuft über <strong>UDP</strong> Port 443. Die Firewall aus Teil 1 lässt aber nur TCP durch. Browser fallen dann still auf HTTP/2 zurück, es funktioniert also trotzdem – sauberer ist es aber, UDP auch freizugeben:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo ufw allow 443/udp
</span></span></code></pre></div><p>Und falls <code>caddy validate</code> meckert, die Datei sei „not formatted“: Das ist reine Kosmetik (Leerzeichen statt Tabs). <code>sudo caddy fmt --overwrite /etc/caddy/Caddyfile</code> räumt das auf.</p>
<h2 id="schritt-5-mond-und-sonne--die-csp-schlägt-zu">Schritt 5: Mond und Sonne – die CSP schlägt zu</h2>
<p>Ein paar Tage später – nun ja, ein paar Stunden – lief die echte Seite: gebaut mit Hugo und dem Theme PaperMod (wie sie dorthin kommt, zeige ich in Teil 3). Und oben neben dem Titel sah ich <strong>Mond und Sonne gleichzeitig</strong>. Eigentlich zeigt PaperMod nur eines der beiden Symbole, je nachdem, ob gerade das helle oder das dunkle Design aktiv ist. Umschalten ging auch nicht.</p>
<p>Die Browser-Konsole (<code>F12</code>) hat den Grund sofort verraten, viermal in Rot:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Executing inline script violates the following Content Security Policy
</span></span><span class="line"><span class="cl">directive &#39;default-src &#39;self&#39;&#39;. Either the &#39;unsafe-inline&#39; keyword,
</span></span><span class="line"><span class="cl">a hash (&#39;sha256-…&#39;), or a nonce (&#39;nonce-…&#39;) is required to enable
</span></span><span class="line"><span class="cl">inline execution.
</span></span></code></pre></div><p>PaperMod hat ein paar kleine Skripte direkt im HTML stehen („Inline-Skripte“), unter anderem für den Hell-Dunkel-Umschalter. Meine strenge CSP erlaubt aber nur Skripte aus eigenen Dateien. Lokal mit <code>hugo server</code> merkt man davon nichts, weil dort keine CSP gesetzt ist – erst auf dem echten Server.</p>
<h3 id="die-bequeme-lösung--und-warum-ich-sie-nicht-nehme">Die bequeme Lösung – und warum ich sie nicht nehme</h3>
<p>Am einfachsten wäre <code>script-src 'self' 'unsafe-inline'</code>. Damit wäre aber jedes Inline-Skript erlaubt, also auch eines, das jemand irgendwann einschleust. Das würde einen großen Teil des Schutzes der CSP aushebeln.</p>
<h3 id="die-saubere-lösung-hashes">Die saubere Lösung: Hashes</h3>
<p>Stattdessen erlaube ich <strong>genau diese</strong> Skripte über ihren SHA-256-Hash. Der Browser berechnet den Hash jedes Inline-Skripts und führt es nur aus, wenn er in der CSP steht. Ein verändertes oder fremdes Skript hat einen anderen Hash und wird weiter blockiert.</p>
<p>Die Hashes stehen zwar in der Fehlermeldung, aber abtippen wollte ich sie nicht: <code>l</code> und <code>I</code>, <code>0</code> und <code>O</code> – ein einziges falsches Zeichen, und der Hash ist wertlos. Also lasse ich sie aus einem lokalen Build berechnen. Das Ergebnis ist identisch, solange lokal und auf dem Server dieselbe Hugo-Version baut:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">hugo --minify -D -d /tmp/kb-csp
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">python3 - <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">import re, hashlib, base64, pathlib
</span></span></span><span class="line"><span class="cl"><span class="s">hashes = set()
</span></span></span><span class="line"><span class="cl"><span class="s">pattern = re.compile(r&#39;&lt;script(?![^&gt;]*\bsrc=)(?![^&gt;]*application/ld\+json)[^&gt;]*&gt;(.*?)&lt;/script&gt;&#39;, re.S | re.I)
</span></span></span><span class="line"><span class="cl"><span class="s">for f in pathlib.Path(&#39;/tmp/kb-csp&#39;).rglob(&#39;*.html&#39;):
</span></span></span><span class="line"><span class="cl"><span class="s">    for m in pattern.finditer(f.read_text(encoding=&#39;utf-8&#39;)):
</span></span></span><span class="line"><span class="cl"><span class="s">        digest = hashlib.sha256(m.group(1).encode(&#39;utf-8&#39;)).digest()
</span></span></span><span class="line"><span class="cl"><span class="s">        hashes.add(&#34;&#39;sha256-&#34; + base64.b64encode(digest).decode() + &#34;&#39;&#34;)
</span></span></span><span class="line"><span class="cl"><span class="s">print(&#34; &#34;.join(sorted(hashes)))
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span></code></pre></div><ul>
<li><code>-D</code> baut auch Entwürfe mit, damit auch Skripte erfasst werden, die nur auf Artikelseiten vorkommen.</li>
<li>JSON-LD-Blöcke (<code>application/ld+json</code>) lasse ich aus. Das sind Daten für Suchmaschinen, die der Browser nicht ausführt.</li>
</ul>
<p>Heraus kamen fünf Hashes, darunter die vier aus der Konsole. Die kommen in die CSP:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddyfile" data-lang="caddyfile"><span class="line"><span class="cl"><span class="gh">Content-Security-Policy</span> <span class="gh">&#34;default-src</span> <span class="gh">&#39;self&#39;;</span> <span class="gh">script-src</span> <span class="gh">&#39;self&#39;</span> <span class="gh">&#39;sha256-&lt;HASH_1&gt;&#39;</span> <span class="gh">&#39;sha256-&lt;HASH_2&gt;&#39;</span> <span class="gh">&#39;sha256-&lt;HASH_3&gt;&#39;</span> <span class="gh">&#39;sha256-&lt;HASH_4&gt;&#39;</span> <span class="gh">&#39;sha256-&lt;HASH_5&gt;&#39;;</span> <span class="gh">img-src</span> <span class="gh">&#39;self&#39;</span> <span class="gh">data:;</span> <span class="gh">object-src</span> <span class="gh">&#39;none&#39;;</span> <span class="gh">base-uri</span> <span class="gh">&#39;self&#39;;</span> <span class="gh">frame-ancestors</span> <span class="gh">&#39;none&#39;&#34;</span><span class="p">
</span></span></span></code></pre></div><p>Vorher sichere ich das Caddyfile, dann <code>validate</code> und <code>reload</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak
</span></span><span class="line"><span class="cl"><span class="c1"># CSP-Zeile anpassen, dann:</span>
</span></span><span class="line"><span class="cl">sudo caddy validate --config /etc/caddy/Caddyfile <span class="o">&amp;&amp;</span> sudo systemctl reload caddy
</span></span></code></pre></div><p>Seite neu geladen (<code>Strg+Shift+R</code>) – und schon war nur noch ein Symbol zu sehen, der Umschalter funktionierte, und die Konsole war leer.</p>
<p><strong>Der Haken:</strong> Ändert sich durch ein Update von PaperMod oder Hugo eines dieser Skripte, stimmt sein Hash nicht mehr. Das merkt man an genau demselben Symptom: Mond und Sonne gleichzeitig. Dann einfach das Skript oben noch einmal laufen lassen und die CSP aktualisieren. Das automatisch in der Pipeline zu prüfen, steht auf meiner Liste.</p>
<h2 id="stolperfallen--learnings">Stolperfallen &amp; Learnings</h2>
<ul>
<li><strong>Cloudflare-Proxy bewusst entscheiden.</strong> Er ist standardmäßig an und hat Folgen für Datenschutz und direkte Server-zu-Server-Verbindungen.</li>
<li><strong><code>caddy validate</code> vor jedem <code>reload</code>.</strong> Das hat mich vor dem Link im Caddyfile bewahrt.</li>
<li><strong>Kopierte Hostnamen prüfen.</strong> Chats und Webseiten verwandeln sie gern in Links – auch beim Zurückkopieren der Ausgabe.</li>
<li><strong>Header gelten pro Block.</strong> Der <code>www</code>-Block braucht sein eigenes <code>header -Server</code>.</li>
<li><strong>HTTP/3 = UDP 443.</strong> In der Firewall freigeben.</li>
<li><strong>CSP mit Hashes statt <code>unsafe-inline</code>.</strong> Die Hashes berechnen statt abtippen, und nach Theme-Updates neu prüfen.</li>
</ul>
<h2 id="fazit--ausblick">Fazit &amp; Ausblick</h2>
<p>Mit einer Handvoll Zeilen Caddyfile liefert der Server die Seite jetzt per HTTPS aus, mit automatisch erneuerten Zertifikaten, ohne Zugriffsprotokolle und mit Security-Headern, die fremde Inhalte konsequent draußen halten. Die CSP hat mich zwar kurz das Theme gekostet, aber genau dafür ist sie da: Sie hat sofort gezeigt, was auf der Seite eigentlich alles ausgeführt wird.</p>
<p><strong>Was als Nächstes kommt:</strong> In Teil 3 baue ich die Pipeline. Ich schreibe Artikel auf meinem Laptop, pushe sie in meinen eigenen Git-Server im Homelab, ein Runner baut die Seite und schiebt sie zum VPS – über einen SSH-Schlüssel, der dort nichts anderes darf, als genau dieses eine Verzeichnis zu beschreiben.</p>
]]></content:encoded></item><item><title>Wie Kabelbruch entstanden ist – Teil 1: Die Idee und der Server</title><link>https://kabelbruch.de/posts/kabelbruch-teil-1-idee-und-server/</link><pubDate>Fri, 30 Oct 2026 23:13:00 +0100</pubDate><guid>https://kabelbruch.de/posts/kabelbruch-teil-1-idee-und-server/</guid><description>Warum ich einen eigenen Tech-Blog starte, wieso er nicht im Homelab läuft und wie ich den kleinen VPS dafür Schritt für Schritt abgesichert habe.</description><content:encoded><![CDATA[<p>Ein Blog über Homelab, Netzwerk und Security, der mit einem Server anfängt – wie sollte es auch anders sein. In diesem ersten Teil geht es darum, warum es Kabelbruch überhaupt gibt, warum die Seite <strong>nicht</strong> in meinem Homelab läuft und wie ich den kleinen Server dafür abgesichert habe. Inklusive der Stellen, an denen ich mich selbst ausgesperrt habe. Fast.</p>
<h2 id="warum-ein-blog--und-warum-kabelbruch">Warum ein Blog – und warum „Kabelbruch“?</h2>
<p>Ich bin ITler, mein Alltag dreht sich um Netzwerk und Security. Und „Kabelbruch“ passte eigentlich schon immer gut zu dem, was ich mache – ich fand den Namen einfach witzig. Ein Kabelbruch ist ja genau die Art Fehler, nach der man stundenlang sucht, obwohl die Konfiguration perfekt ist.</p>
<p>Den Blog gibt es, weil ich meine Projekte gern teilen möchte: was ich so baue, woran ich scheitere und welche Erfolge ich habe. Deshalb steht über allem das Motto: <strong>Basteln, ausprobieren, auch mal scheitern und darüber schreiben.</strong> Die Fehlschläge gehören hier ausdrücklich dazu – meistens lernt man aus denen sowieso mehr.</p>
<p>Außerdem bin ich ein großer Fan von Open Source und versuche, in meinem privaten Umfeld wo immer möglich darauf zu setzen. Das zieht sich auch durch diese Seite: Vom Betriebssystem über den Webserver bis zum Git-Server und dem Seitengenerator ist hier alles Open Source.</p>
<p>Und weil ein Blog über Homelab und Security irgendwo laufen muss, ist sein Aufbau gleich das erste Projekt. Daraus ist diese kleine Serie geworden:</p>
<ol>
<li><strong>Die Idee und der Server</strong> (dieser Teil)</li>
<li>Caddy, DNS und Security-Header</li>
<li>Die Pipeline: vom <code>git push</code> zur fertigen Seite</li>
<li>Mail, Impressum und Favicon</li>
</ol>
<h2 id="die-grundsatzentscheidungen">Die Grundsatzentscheidungen</h2>
<h3 id="statisch-statt-wordpress">Statisch statt WordPress</h3>
<p>Ich wollte eine Seite, die ich verstehe, die wenig Pflege braucht und kaum Angriffsfläche bietet. Deshalb ist Kabelbruch eine <strong>statische Website</strong>, gebaut mit <a href="https://gohugo.io/" target="_blank" rel="noopener noreferrer">Hugo</a>. Ein statischer Seitengenerator macht aus Markdown-Dateien fertige HTML-Seiten. Auf dem Server liegen am Ende nur noch Dateien – keine Datenbank, kein PHP, kein Login-Formular, das man angreifen könnte.</p>
<p>Die Artikel schreibe ich als Markdown-Dateien in einem Git-Repository. Das hat einen netten Nebeneffekt: Ich habe eine Versionsgeschichte für jeden Text und kann Code-Beispiele direkt mit Syntax-Hervorhebung einbauen.</p>
<h3 id="nicht-im-homelab">Nicht im Homelab</h3>
<p>Natürlich war die erste Idee, die Seite einfach zu Hause auf meinem Proxmox-Server laufen zu lassen. Ich habe mich trotzdem dagegen entschieden:</p>
<ul>
<li>Eine öffentliche Website im Heimnetz bedeutet eine Portfreigabe oder einen Tunnel, ein eigenes DMZ-Netz und deutlich mehr Monitoring. Das kann man machen, aber es muss gut durchdacht sein.</li>
<li>Fällt zu Hause der Strom oder das Internet aus, wäre der Blog auch weg.</li>
</ul>
<p>Mein Kompromiss: <strong>Gebaut wird die Seite im Homelab, ausgeliefert von einem kleinen Server im Rechenzentrum.</strong> Mein Heimnetz muss dafür nichts nach außen öffnen – die Verbindung geht nur von innen nach außen. Wie das genau funktioniert, zeige ich in Teil 3.</p>
<h2 id="welcher-hoster">Welcher Hoster?</h2>
<p>Meine erste Wahl wäre Hetzner gewesen. Allerdings hat Hetzner 2026 kräftig an der Preisschraube gedreht: Zum April stiegen die Cloud-Server-Preise im Schnitt um rund 32 Prozent, im Juni folgte für einige Server-Linien eine weitere Runde. Ganz allein ist Hetzner damit nicht – auch andere Anbieter haben erhöht, Grund sind vor allem die gestiegenen Preise für Arbeitsspeicher. Trotzdem wollte ich mir die Alternativen ansehen.</p>
<p>Die gute Nachricht: Für eine statische Seite braucht man fast nichts. Ich habe mich für einen kleinen VPS (Virtual Private Server, also eine eigene virtuelle Maschine beim Anbieter) bei <strong>netcup</strong> entschieden:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Tarif</td>
					<td>VPS nano G11.5s</td>
			</tr>
			<tr>
					<td>CPU / RAM / SSD</td>
					<td>2 vCores / 2 GB / 60 GB</td>
			</tr>
			<tr>
					<td>Preis</td>
					<td>3,69 € im Monat inkl. MwSt. (6 Monate Laufzeit, Stand Oktober 2026)</td>
			</tr>
			<tr>
					<td>Standort</td>
					<td>Deutschland</td>
			</tr>
			<tr>
					<td>Betriebssystem</td>
					<td>Debian 13 „trixie“</td>
			</tr>
	</tbody>
</table>
<p>Für die Website allein ist das sogar überdimensioniert. Aber ich habe schon die nächste Idee im Hinterkopf (dazu am Ende mehr), und dafür ist ein bisschen Luft ganz praktisch.</p>
<p><strong>Ein Tipp beim Preisvergleich:</strong> Manche Anbieter zeigen Preise netto, andere inklusive 19 % MwSt. Wer das nicht beachtet, vergleicht Äpfel mit Birnen.</p>
<p><strong>Und noch etwas Rechtliches:</strong> Sobald personenbezogene Daten auf dem Server landen – und eine IP-Adresse zählt schon dazu –, gehört ein <strong>Vertrag zur Auftragsverarbeitung</strong> (AV-Vertrag nach Art. 28 DSGVO) mit dem Hoster dazu. Bei netcup lässt sich der im Kundenmenü abschließen. Ich bin kein Anwalt, aber das ist eine Sache von zehn Minuten und sollte man einfach machen.</p>
<h2 id="voraussetzungen">Voraussetzungen</h2>
<p>Was ich für diesen Teil benutzt habe:</p>
<ul>
<li>Einen VPS mit <strong>Debian 13</strong> (frisch installiert, Root-Zugang per SSH)</li>
<li>Einen Linux-Rechner mit OpenSSH-Client</li>
<li>Einen vorhandenen SSH-Schlüssel vom Typ <strong>ed25519</strong></li>
</ul>
<p>In allen Beispielen steht <code>203.0.113.10</code> für die IP meines Servers und <code>thomas</code> für meinen Benutzer. Setz dort deine eigenen Werte ein.</p>
<h2 id="schritt-1-den-ssh-schlüssel-hinterlegen">Schritt 1: Den SSH-Schlüssel hinterlegen</h2>
<p>Ein SSH-Schlüssel ist sicherer als jedes Passwort – sofern man den richtigen erwischt. Bei mir lagen schon zwei Schlüssel in <code>~/.ssh/</code>, einer davon für meinen Git-Server. Mit <code>ssh-keygen -lf</code> sieht man, welcher Schlüssel welchen Kommentar trägt, und damit meistens auch, wofür er gedacht war:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ls ~/.ssh/*.pub
</span></span><span class="line"><span class="cl">ssh-keygen -lf ~/.ssh/id_ed25519.pub
</span></span></code></pre></div><p>Den öffentlichen Teil (die <code>.pub</code>-Datei – der private Schlüssel verlässt den eigenen Rechner nie) kopiere ich auf den Server:</p>
<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 root@203.0.113.10
</span></span></code></pre></div><p>Damit ich mir die IP nicht merken muss, bekommt der Server einen Eintrag in der <code>~/.ssh/config</code> auf meinem Rechner:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Host kabelbruch
</span></span><span class="line"><span class="cl">    HostName 203.0.113.10
</span></span><span class="line"><span class="cl">    User root
</span></span><span class="line"><span class="cl">    IdentityFile ~/.ssh/id_ed25519
</span></span><span class="line"><span class="cl">    IdentitiesOnly yes
</span></span></code></pre></div><p><code>IdentitiesOnly yes</code> sorgt dafür, dass genau dieser Schlüssel benutzt wird und SSH nicht der Reihe nach alle durchprobiert. Ab jetzt reicht <code>ssh kabelbruch</code>.</p>
<h2 id="schritt-2-ein-eigener-benutzer-statt-root">Schritt 2: Ein eigener Benutzer statt root</h2>
<p>Als <code>root</code> zu arbeiten ist bequem, aber genau deshalb keine gute Idee. Ich lege mir einen eigenen Benutzer an, der per <code>sudo</code> Administratorrechte bekommt:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">apt update <span class="o">&amp;&amp;</span> apt install -y sudo
</span></span><span class="line"><span class="cl">adduser thomas
</span></span><span class="line"><span class="cl">usermod -aG sudo thomas
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">mkdir -p /home/thomas/.ssh
</span></span><span class="line"><span class="cl">cp /root/.ssh/authorized_keys /home/thomas/.ssh/
</span></span><span class="line"><span class="cl">chown -R thomas:thomas /home/thomas/.ssh
</span></span><span class="line"><span class="cl">chmod <span class="m">700</span> /home/thomas/.ssh
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> /home/thomas/.ssh/authorized_keys
</span></span></code></pre></div><p>Das Passwort, das <code>adduser</code> abfragt, brauche ich später nur noch für <code>sudo</code>, nicht mehr zum Anmelden.</p>
<p><strong>Wichtig:</strong> Bevor man irgendetwas am SSH-Zugang ändert, immer eine zweite Sitzung offen lassen. Das ist der Rettungsanker, falls man sich aussperrt.</p>
<h2 id="schritt-3-passwörter-und-root-login-abschalten">Schritt 3: Passwörter und root-Login abschalten</h2>
<p>Jetzt kommt der eigentliche Härtungsschritt. Ich lege die Einstellungen in einer eigenen Datei unter <code>sshd_config.d</code> ab, statt die Hauptkonfiguration zu bearbeiten:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo tee /etc/ssh/sshd_config.d/00-hardening.conf &gt; /dev/null <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">PasswordAuthentication no
</span></span></span><span class="line"><span class="cl"><span class="s">KbdInteractiveAuthentication no
</span></span></span><span class="line"><span class="cl"><span class="s">PermitRootLogin no
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">sudo sshd -t <span class="o">&amp;&amp;</span> sudo systemctl reload ssh
</span></span><span class="line"><span class="cl">sudo sshd -T <span class="p">|</span> grep -E <span class="s1">&#39;passwordauth|permitroot&#39;</span>
</span></span></code></pre></div><p>Die Ausgabe sollte <code>passwordauthentication no</code> und <code>permitrootlogin no</code> zeigen.</p>
<p>Warum <code>00-</code> im Dateinamen? Bei <code>sshd</code> gewinnt der <strong>erste</strong> gefundene Wert. Manche Server-Images bringen eine eigene Datei mit, zum Beispiel <code>50-cloud-init.conf</code> mit <code>PasswordAuthentication yes</code>. Mit <code>00-</code> am Anfang wird meine Datei zuerst gelesen und setzt sich durch.</p>
<h3 id="gescheitert-teil-1-permission-denied">Gescheitert, Teil 1: <code>Permission denied</code></h3>
<p>Mein erster Versuch sah so aus:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cat &gt; /etc/ssh/sshd_config.d/00-hardening.conf <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">...
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span></code></pre></div><p>Ergebnis: <code>Permission denied</code> – und gleich danach <code>sshd: command not found</code>. Ich war schon als <code>thomas</code> angemeldet, nicht mehr als root. Und hier kommt eine klassische Falle: Ein <code>sudo</code> vor dem Befehl hilft bei der Umleitung <code>&gt;</code> <strong>nicht</strong>, denn die Umleitung macht die Shell, und die läuft weiterhin als normaler Benutzer. Die Lösung ist <code>sudo tee</code>, wie oben im Beispiel. Das <code>sshd: command not found</code> hat übrigens einen ganz harmlosen Grund: <code>sshd</code> liegt in <code>/usr/sbin</code>, und das steht bei normalen Benutzern nicht im Pfad. Mit <code>sudo</code> davor wird es gefunden.</p>
<h3 id="gescheitert-teil-2-plötzlich-komme-ich-nicht-mehr-rein">Gescheitert, Teil 2: Plötzlich komme ich nicht mehr rein</h3>
<p>Nach der Härtung wollte ich mich testweise neu verbinden:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ ssh kabelbruch
</span></span><span class="line"><span class="cl">root@203.0.113.10: Permission denied (publickey).
</span></span></code></pre></div><p>Kurzer Schreckmoment. Dabei hat schlicht alles funktioniert: In meiner <code>~/.ssh/config</code> stand noch <code>User root</code> – und root darf sich ja jetzt nicht mehr anmelden. Die Sperre hat also genau das getan, was sie sollte.</p>
<p>Ich habe dann <code>grep</code> auf die <code>~/.ssh/config</code> losgelassen, um nachzusehen – allerdings im falschen Terminal, nämlich auf dem Server statt auf meinem Laptop. Dort gibt es diese Datei natürlich nicht. Eine Faustregel, die ich mir seitdem merke: <strong>Steht im Prompt der Name des Servers, bin ich auf dem Server.</strong> Alles rund um den eigenen SSH-Schlüssel und die SSH-Config passiert lokal.</p>
<p>Nachdem in der Config <code>User thomas</code> stand, ging es sofort. Der Gegentest gehört trotzdem dazu:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ssh kabelbruch            <span class="c1"># klappt als thomas</span>
</span></span><span class="line"><span class="cl">ssh root@203.0.113.10     <span class="c1"># wird abgelehnt</span>
</span></span></code></pre></div><h2 id="schritt-4-die-firewall">Schritt 4: Die Firewall</h2>
<p>Als Nächstes kommt eine einfache Firewall. Ich nutze <code>ufw</code> (Uncomplicated Firewall), die macht ihrem Namen alle Ehre:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt install -y ufw
</span></span><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 OpenSSH
</span></span><span class="line"><span class="cl">sudo ufw allow 80/tcp
</span></span><span class="line"><span class="cl">sudo ufw allow 443/tcp
</span></span><span class="line"><span class="cl">sudo ufw <span class="nb">enable</span>
</span></span><span class="line"><span class="cl">sudo ufw status verbose
</span></span></code></pre></div><p>Die Reihenfolge ist entscheidend: <strong>SSH erlauben, bevor man <code>ufw enable</code> ausführt.</strong> Sonst sperrt man sich mit dem Aktivieren selbst aus. <code>ufw</code> warnt beim Einschalten trotzdem, dass bestehende SSH-Verbindungen unterbrochen werden könnten. Diese Warnung kommt immer, egal welche Regeln existieren. Wenn die SSH-Regel drin ist, kann man sie mit <code>y</code> bestätigen.</p>
<p>Am Ende sind nur noch SSH (22), HTTP (80) und HTTPS (443) offen, für IPv4 und IPv6. Port 80 und 443 brauche ich in Teil 2 für den Webserver und die TLS-Zertifikate.</p>
<h2 id="schritt-5-automatische-sicherheitsupdates">Schritt 5: Automatische Sicherheitsupdates</h2>
<p>Ein Server, der sich nicht selbst aktualisiert, wird irgendwann vergessen. Debian bringt dafür <code>unattended-upgrades</code> mit:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt install -y unattended-upgrades apt-listchanges
</span></span><span class="line"><span class="cl">sudo dpkg-reconfigure -plow unattended-upgrades
</span></span></code></pre></div><p>Im Dialog „Ja“ wählen, fertig. Sicherheitsupdates werden ab jetzt täglich automatisch eingespielt.</p>
<h2 id="stolperfallen--learnings">Stolperfallen &amp; Learnings</h2>
<ul>
<li><strong>Immer eine zweite SSH-Sitzung offen lassen</strong>, solange man am Zugang schraubt.</li>
<li><strong><code>sudo</code> hilft nicht bei <code>&gt;</code></strong>, dafür gibt es <code>sudo tee</code>.</li>
<li><strong><code>00-</code> im Dateinamen unter <code>sshd_config.d</code></strong>, weil der erste Wert gewinnt.</li>
<li><strong>Erst SSH in der Firewall erlauben, dann <code>ufw enable</code>.</strong></li>
<li><strong>Auf den Prompt schauen:</strong> Laptop oder Server? Die Hälfte meiner Fehler an diesem Abend waren Befehle im falschen Terminal.</li>
<li><strong><code>Permission denied (publickey)</code> nach der Härtung</strong> ist oft gar kein Fehler, sondern die neue Sperre bei der Arbeit. Erst die eigene SSH-Config prüfen.</li>
</ul>
<h2 id="fazit--ausblick">Fazit &amp; Ausblick</h2>
<p>Nach etwa einer Stunde hatte ich einen Server, auf dem man sich nur noch mit meinem Schlüssel und nicht als root anmelden kann, bei dem nur drei Ports offen sind und der sich selbst mit Sicherheitsupdates versorgt. Das ist kein Hochsicherheitstrakt, aber eine solide Grundlage – und genau das, was ein öffentlicher Server mindestens braucht.</p>
<p><strong>Was als Nächstes kommt:</strong> In Teil 2 installiere ich Caddy als Webserver, stelle das DNS ein und setze Security-Header. Dabei hat mich eine Content-Security-Policy eine Weile beschäftigt: Plötzlich waren auf meiner Seite Mond <em>und</em> Sonne gleichzeitig zu sehen.</p>
<p>Und die Idee im Hinterkopf, für die der Server ein bisschen Luft hat: ein eigener kleiner Fediverse-Server. Aber dazu mehr, wenn es so weit ist.</p>
]]></content:encoded></item></channel></rss>