<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>The Self-Host Journal</title>
	<atom:link href="https://selfhostjournal.eu/de/feed/" rel="self" type="application/rss+xml" />
	<link>https://selfhostjournal.eu</link>
	<description>Echte Setups, echte Lösungen</description>
	<lastBuildDate>Fri, 23 Oct 2026 19:20:54 +0000</lastBuildDate>
	<language>de</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.3</generator>

<image>
	<url>https://selfhostjournal.eu/wp-content/uploads/2026/10/site-icon-terminal-150x150.png</url>
	<title>The Self-Host Journal</title>
	<link>https://selfhostjournal.eu</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Traefik GeoBlock hat meinen eigenen KI-Assistenten ausgesperrt: einen veröffentlichten IP-Bereich erlauben</title>
		<link>https://selfhostjournal.eu/de/traefik-geoblock-ip-bereich-erlauben/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 19:20:54 +0000</pubDate>
				<category><![CDATA[Fehlerbehebung]]></category>
		<category><![CDATA[Sicherheit und SSO]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=167</guid>

					<description><![CDATA[Nach dem Einschalten von GeoBlock funktionierte der Connector von Claude zu Home Assistant nicht mehr, alles andere schon. Die Logs zeigten den Grund, und ein Eintrag in allowedIPAddresses hat es gelöst.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">Kurz gesagt</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Nachdem ich Traefik GeoBlock eingeschaltet hatte, funktionierte der Connector von Claude zu Home Assistant nicht mehr. Home Assistant selbst lief für alle zu Hause ganz normal.</p>



<p class="wp-block-paragraph"><strong>Lösung:</strong> Der Connector ruft von den Servern von Anthropic in den USA an, und GeoBlock ließ nur meine eigenen Länder durch. Ein Eintrag in <code>allowedIPAddresses</code> mit dem veröffentlichten IP-Bereich des Anbieters hat das Problem gelöst.</p>



<p class="wp-block-paragraph"><strong>Getestet mit:</strong> Traefik 3, GeoBlock-Plugin (PascalMinder) 0.3.8, Home Assistant mit einem MCP-Server für Claude</p>
</div>



<p class="wp-block-paragraph">In <a href="/de/traefik-absichern-geoblock-crowdsec-sso/">Traefik absichern</a> habe ich GeoBlock vor jeden Dienst gesetzt: Nur Besucher aus den Ländern, in denen meine Familie lebt, kommen durch, alle anderen bekommen eine 403. Das hat genau wie geplant funktioniert. Etwas zu gut, wie sich herausstellte: Es hat auch einen Besucher ausgesperrt, den ich haben wollte, meinen eigenen KI-Assistenten.</p>



<p class="wp-block-paragraph">Das ist eine kurze Geschichte über eine Sicherheitsmaßnahme, die ihren Job macht, dabei etwas kaputt macht, das du brauchst, und wie du herausfindest, welche es war.</p>



<h2 class="wp-block-heading">1. Das Symptom</h2>



<p class="wp-block-paragraph">Ich nutze Claude mit einem Connector zu Home Assistant, damit ich Sensoren, Automationen und Logs in normaler Sprache abfragen kann. Kurz nach dem Absichern erreichte der Connector Home Assistant nicht mehr. Jeder Versuch scheiterte.</p>



<p class="wp-block-paragraph">Gleichzeitig war mit Home Assistant alles in Ordnung: Das Dashboard lud zu Hause, die App auf dem Handy funktionierte, die Automationen liefen. Betroffen war nur dieser eine Client.</p>



<h2 class="wp-block-heading">2. Was ich zuerst geprüft habe (und warum mich das in die Irre geführt hat)</h2>



<p class="wp-block-paragraph">Ein Connector, der sich per OAuth anmeldet, hat viele Teile, die kaputtgehen können. Also schaut man zuerst dort:</p>



<ul class="wp-block-list">
<li><strong>Anmeldung und Tokens:</strong> Ein abgelaufenes Token oder eine kaputte OAuth-Einrichtung würde einen streikenden Client erklären. Neu verbinden hilft aber nicht, wenn die Anfragen blockiert werden, bevor sie ankommen.</li>



<li><strong>Home-Assistant-Logs:</strong> nichts. Kein fehlgeschlagener Login, kein Fehler der MCP-Integration, nicht einmal eine Anfrage.</li>



<li><strong>Die URL:</strong> Sie öffnet sich im Browser ganz normal.</li>
</ul>



<p class="wp-block-paragraph">Das leere Home-Assistant-Log war der eigentliche Hinweis, und man liest es leicht falsch. &#8222;Keine Fehler&#8220; klang nach &#8222;in Home Assistant ist alles in Ordnung&#8220;. Tatsächlich hieß es: Die Anfragen kamen nie an. Etwas vor Home Assistant hat sie beantwortet. Und &#8222;im Browser geht es&#8220; bewies gar nichts, denn mein Browser kommt aus einem erlaubten Land.</p>



<h2 class="wp-block-heading">3. Die eigentliche Ursache: Traefik GeoBlock hat seinen Job gemacht</h2>



<p class="wp-block-paragraph">Die Connectors von Claude laufen nicht in meinem Browser oder auf meinem Handy. Sie rufen meinen Server aus der Infrastruktur von Anthropic auf, und die steht in den USA. Für GeoBlock ist das einfach eine weitere Anfrage aus einem Land, das nicht auf der Liste steht.</p>



<p class="wp-block-paragraph">Zwei Logs zeigen es. GeoBlock schreibt jede abgelehnte Anfrage in eine eigene Datei. Eine Zeile sieht so aus (Beispieladresse):</p>



<pre class="wp-block-code"><code>INFO: GeoBlock: 2026/10/16 09:14:52 geoblock@docker: request denied [203.0.113.17] for country [US] due to: country is not allowed</code></pre>



<p class="wp-block-paragraph">Und das Access-Log von Traefik zeigt die 403 mit leerem Backend (<code>"-"</code> nach dem Router-Namen). Der Strich heißt: Die Anfrage hat den Dienst nie erreicht (wieder eine Beispielzeile):</p>



<pre class="wp-block-code"><code>203.0.113.17 - - [16/Oct/2026:09:14:52 +0000] "POST /api/webhook/… HTTP/1.1" 403 0 "-" "-" 4711 "homeassistant@docker" "-" 1ms</code></pre>



<p class="wp-block-paragraph">Das Log ist voll mit Scannern aus aller Welt, die Anfragen des Connectors fallen darin also nicht von selbst auf. Vergleichst du die abgelehnten Adressen mit dem Bereich, den Anthropic für seinen ausgehenden Verkehr veröffentlicht, dann schon.</p>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Wenn ein einzelner Client scheitert und das Log der App leer ist, schau zuerst auf den Reverse Proxy. Eine 403 ohne Backend im Access-Log ist das typische Zeichen dafür, dass eine Middleware Nein gesagt hat.</p>
</div>



<h2 class="wp-block-heading">4. Die Lösung: den veröffentlichten IP-Bereich erlauben</h2>



<p class="wp-block-paragraph">GeoBlock hat genau dafür eine Option: <code>allowedIPAddresses</code>. Adressen in dieser Liste kommen durch, egal aus welchem Land. Ein Label mehr am Traefik-Container:</p>



<pre class="wp-block-code"><code>      # Claude connectors call from Anthropic's published outbound range
      # source: https://platform.claude.com/docs/en/api/ip-addresses
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowedIPAddresses[0]=160.79.104.0/21"</code></pre>



<p class="wp-block-paragraph">Dann den Container neu erstellen, damit Traefik die neuen Labels liest:</p>



<pre class="wp-block-code"><code>docker compose up -d traefik</code></pre>



<p class="wp-block-paragraph">Der Bereich oben ist der, den Anthropic zum Zeitpunkt des Schreibens angibt. Prüf auf der <a href="https://platform.claude.com/docs/en/api/ip-addresses" target="_blank" rel="noreferrer noopener">Seite mit den IP-Adressen von Anthropic</a> den aktuellen Stand, bevor du ihn übernimmst. Derselbe Weg funktioniert für jeden anderen Dienst, der dich aus dem Ausland erreichen muss, solange der Anbieter seine IP-Bereiche veröffentlicht.</p>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Achtung:</strong> Erlaube nur Bereiche, die der Anbieter offiziell veröffentlicht. Trag nie eine IP aus deinem eigenen Log ein, nur weil sie passend aussieht, und öffne nie ein ganzes Land für einen einzelnen Dienst. Denk auch daran, was eine Ausnahme bedeutet: Diesen Bereich teilen sich alle Kunden des Anbieters, nicht nur du. GeoBlock lässt sie alle durch, also muss die Anmeldung des Dienstes dahinter trotzdem halten. Bei mir bleiben die eigene Anmeldung von Home Assistant und CrowdSec davor.</p>
</div>



<h2 class="wp-block-heading">5. So prüfst du es</h2>



<ol class="wp-block-list">
<li>Prüfen, ob Traefik das Label übernommen hat:<br><code>docker inspect traefik --format '{{ index .Config.Labels "traefik.http.middlewares.geoblock.plugin.geoblock.allowedIPAddresses[0]" }}'</code></li>



<li>Den Connector einmal benutzen (bei mir: Claude nach einem Sensorwert fragen).</li>



<li>Sicherstellen, dass der Bereich nicht mehr als abgelehnt auftaucht:<br><code>grep -E '\[160\.79\.1(0[4-9]|1[01])\.' traefik-logs/geoblock.log | tail</code><br>Keine neuen Zeilen nach dem Neustart heißt: GeoBlock lässt den Connector durch.</li>
</ol>



<h2 class="wp-block-heading">Was ich gelernt habe</h2>



<ul class="wp-block-list">
<li>&#8222;Im Browser geht es&#8220; ist kein Test für eine Geo-Sperre. Frag dich, von wo der streikende Client wirklich anruft.</li>



<li>Ein leeres Log der Anwendung ist ein Befund, keine Sackgasse: Die Anfrage wurde vorher gestoppt.</li>



<li>Bevor du eine Allow-Liste einschaltest, schreib jeden Dienst auf, der von außen anruft: KI-Connectors, Webhooks, Monitoring, Push-Dienste. Jeder davon braucht eine bewusste Ausnahme oder einen anderen Weg.</li>
</ul>



<p class="wp-block-paragraph">Das komplette Setup mit GeoBlock und CrowdSec steht in <a href="/de/traefik-absichern-geoblock-crowdsec-sso/">Traefik absichern: Geo-Blocking, CrowdSec und Single Sign-on</a>.</p>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Authentik: &#8222;The request is otherwise malformed&#8220; nach dem Anlegen eines Providers über die API</title>
		<link>https://selfhostjournal.eu/de/authentik-request-otherwise-malformed-api-provider/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 19:09:13 +0000</pubDate>
				<category><![CDATA[Fehlerbehebung]]></category>
		<category><![CDATA[Sicherheit und SSO]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=162</guid>

					<description><![CDATA[Jeder Login über einen neuen Authentik-Provider scheiterte mit invalid_request. Die Ursache war ein fehlendes Feld im API-Aufruf, und die Fehlermeldung im Browser zeigt in die falsche Richtung.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">Kurz gesagt</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Jeder Login über einen neuen OAuth2/OpenID-Provider in Authentik scheitert mit <code>invalid_request</code>: &#8222;The request is otherwise malformed&#8220;.</p>



<p class="wp-block-paragraph"><strong>Lösung:</strong> Der Provider wurde über die API angelegt, und seine Liste <code>grant_types</code> war leer. Setz sie auf <code>authorization_code</code> und <code>refresh_token</code> und teste danach die Authorize-URL mit curl.</p>



<p class="wp-block-paragraph"><strong>Getestet mit:</strong> Authentik 2026.8, WordPress mit dem Plugin OpenID Connect Generic 3.11</p>
</div>



<p class="wp-block-paragraph">Die meisten Apps lege ich in Authentik über die Admin-Oberfläche an. Für diesen Blog wollte ich es per Skript machen und habe den OAuth2-Provider mit einem einzigen API-Aufruf erstellt. Der Aufruf klappte, die Anwendung tauchte auf, das WordPress-Plugin war mit der richtigen Client-ID, dem Secret und den Endpunkten eingerichtet. Und dann funktionierte kein einziger Login.</p>



<p class="wp-block-paragraph">Die Lösung war am Ende ein einziges Feld. Sie zu finden hat länger gedauert als nötig, weil die Fehlermeldung in die falsche Richtung zeigt.</p>



<h2 class="wp-block-heading">1. Das Symptom: &#8222;request is otherwise malformed&#8220;</h2>



<p class="wp-block-paragraph">Ein Klick auf &#8222;Login with OpenID Connect&#8220; in WordPress schickte mich zu Authentik, und Authentik schickte mich sofort zurück, mit einer Fehlerseite statt eines Login-Formulars:</p>



<pre class="wp-block-code"><code>invalid_request
The request is otherwise malformed</code></pre>



<p class="wp-block-paragraph">Keine Abfrage von Benutzername oder Passwort. Authentik hat die Anfrage abgelehnt, bevor der Login-Flow überhaupt begonnen hat.</p>



<h2 class="wp-block-heading">2. Was ich zuerst geprüft habe (und warum mich das in die Irre geführt hat)</h2>



<p class="wp-block-paragraph">&#8222;Malformed request&#8220; klingt so, als hätte der Client etwas Falsches geschickt. Also bin ich die üblichen Verdächtigen auf der WordPress-Seite durchgegangen:</p>



<ul class="wp-block-list">
<li><strong>Redirect-URI:</strong> Zeichen für Zeichen mit dem Provider verglichen. Authentik vergleicht strikt, schon ein fehlender Schrägstrich oder ein anderer Query-String reicht für einen Fehler. Sie stimmte.</li>



<li><strong>Client-ID:</strong> noch einmal kopiert, keine Leerzeichen am Rand.</li>



<li><strong>Scopes:</strong> <code>openid email profile</code>, alle drei im Provider vorhanden.</li>



<li><strong>Endpunkte:</strong> aus der <code>.well-known/openid-configuration</code> des Providers übernommen.</li>
</ul>



<p class="wp-block-paragraph">Alles war richtig. Die Anfrage von WordPress war in Ordnung. Das Problem lag auf der Seite von Authentik, und das verrät dir die Fehlermeldung im Browser nicht.</p>



<h2 class="wp-block-heading">3. Die eigentliche Ursache: eine leere grant_types-Liste</h2>



<p class="wp-block-paragraph">Das Server-Log von Authentik hatte die hilfreichere Meldung. Mit Docker Compose:</p>



<pre class="wp-block-code"><code>docker compose logs server --since 10m | grep -i grant</code></pre>



<p class="wp-block-paragraph">Dort stand &#8222;Invalid grant_type for provider&#8220;. Jeder OAuth2-Provider in Authentik hat eine Liste erlaubter Grant Types. Legst du einen Provider in der Admin-Oberfläche an, füllt das Formular sinnvolle Standardwerte ein. Die API macht das nicht: <code>grant_types</code> ist ein optionales Feld, und wenn du es weglässt, wird der Provider mit einer <strong>leeren Liste</strong> gespeichert. Eine leere Liste heißt: kein Grant Type ist erlaubt, nicht einmal der normale Authorization-Code-Flow.</p>



<p class="wp-block-paragraph">Mein API-Aufruf sah ungefähr so aus (gekürzt):</p>



<pre class="wp-block-code"><code>curl -X POST https://id.example.com/api/v3/providers/oauth2/ \
  -H "Authorization: Bearer $AUTHENTIK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Provider for My Blog",
    "client_type": "confidential",
    "authorization_flow": "&lt;flow uuid&gt;",
    "invalidation_flow": "&lt;flow uuid&gt;",
    "redirect_uris": [
      {"matching_mode": "strict",
       "url": "https://blog.example.com/wp-admin/admin-ajax.php?action=openid-connect-authorize"}
    ],
    "signing_key": "&lt;certificate uuid&gt;",
    "property_mappings": ["&lt;openid&gt;", "&lt;email&gt;", "&lt;profile&gt;"]
  }'</code></pre>



<p class="wp-block-paragraph">Alle Pflichtfelder sind da, also nimmt die API den Aufruf ohne Warnung an. Genau das eine Feld, an dem hängt, ob sich überhaupt jemand anmelden kann, fehlt.</p>



<h2 class="wp-block-heading">4. Die Lösung</h2>



<p class="wp-block-paragraph">Setz die Grant Types am bestehenden Provider. Ein Login-Flow für eine Website braucht <code>authorization_code</code>, und <code>refresh_token</code>, wenn die App ihre Tokens erneuert:</p>



<pre class="wp-block-code"><code>curl -X PATCH https://id.example.com/api/v3/providers/oauth2/&lt;provider id&gt;/ \
  -H "Authorization: Bearer $AUTHENTIK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"grant_types": ["authorization_code", "refresh_token"]}'</code></pre>



<p class="wp-block-paragraph">Du kannst den Provider auch in der Admin-Oberfläche öffnen und ihn dort mit beiden Grant Types angehakt speichern. Bei neuen Providern gehört die Zeile gleich in den Aufruf zum Anlegen:</p>



<pre class="wp-block-code"><code>    "grant_types": ["authorization_code", "refresh_token"],</code></pre>



<p class="wp-block-paragraph">Wenn du schon dabei bist, vergleich auch die Token-Laufzeiten (<code>access_token_validity</code>, <code>refresh_token_threshold</code>) mit einem Provider, den du in der Oberfläche angelegt hast. Über die API angelegte Provider bekommen nicht immer dieselben Werte, und gleich eingestellte Provider sind leichter zu pflegen.</p>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Achtung:</strong> Erlaube nur die Grant Types, die die App wirklich nutzt. <code>password</code> und <code>client_credentials</code> umgehen den Login-Flow und damit auch deine MFA und deine Richtlinien. Ein Website-Login braucht sie nicht.</p>
</div>



<h2 class="wp-block-heading">5. So prüfst du es (ohne Browser)</h2>



<p class="wp-block-paragraph">Um einen Provider zu testen, musst du dich nicht durch den Login klicken. Ruf den Authorize-Endpunkt mit curl auf und schau, wohin Authentik dich schickt:</p>



<pre class="wp-block-code"><code>curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
  "https://id.example.com/application/o/authorize/?client_id=&lt;client id&gt;&amp;response_type=code&amp;scope=openid&amp;redirect_uri=https%3A%2F%2Fblog.example.com%2Fwp-admin%2Fadmin-ajax.php%3Faction%3Dopenid-connect-authorize"</code></pre>



<ul class="wp-block-list">
<li><strong>Funktioniert:</strong> ein <code>302</code> zum Login-Flow von Authentik, zum Beispiel <code>/if/flow/default-authentication-flow/…</code>.</li>



<li><strong>Kaputt:</strong> ein <code>302</code> zurück zur Redirect-URI deiner App mit <code>error=…</code> in der URL. Der Name des Fehlers sagt dir, was Authentik nicht gepasst hat.</li>
</ul>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Die Redirect-URI im curl-Aufruf muss URL-kodiert sein und genau zum Provider passen, sonst testest du das falsche Problem. Ich mache diesen Test inzwischen nach jedem neuen Provider, egal ob über die API oder die Oberfläche angelegt. Er dauert fünf Sekunden.</p>
</div>



<h2 class="wp-block-heading">Was ich gelernt habe</h2>



<ul class="wp-block-list">
<li>&#8222;Malformed request&#8220; im Browser heißt nicht, dass der Client falsch ist. Lies das Server-Log des Identity Providers, bevor du in der App etwas änderst.</li>



<li>Admin-Oberfläche und API legen nicht dasselbe Objekt an. Die Oberfläche füllt Standardwerte ein, die API speichert genau das, was du schickst.</li>



<li>Ein curl-Test der Authorize-URL prüft einen Provider in Sekunden, ganz ohne Login.</li>
</ul>



<p class="wp-block-paragraph">Wie der Blog-Login von Anfang bis Ende eingerichtet ist, steht in <a href="/de/wordpress-sso-mit-authentik/">Single Sign-on für WordPress mit Authentik</a>. Alle Felder eines Providers beschreibt die <a href="https://docs.goauthentik.io/" target="_blank" rel="noreferrer noopener">offizielle Authentik-Dokumentation</a>.</p>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Nextcloud in Docker hinter Traefik: meine Konfiguration erklärt</title>
		<link>https://selfhostjournal.eu/de/nextcloud-docker-traefik-konfiguration/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 18:45:18 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=156</guid>

					<description><![CDATA[Mein Nextcloud-Setup in Docker: PostgreSQL, Redis und Cron, die Reverse-Proxy-Einstellungen, auf die es hinter Traefik ankommt, Single Sign-on mit Authentik, Nextcloud Office mit dem eingebauten Collabora-Server und nächtliche Backups.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">Kurz gesagt</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Ein Nextcloud-Container läuft in einer Minute. Damit er schnell ist, über HTTPS erreichbar, ohne Warnungen in der Admin-Übersicht und sicher zu sichern, braucht es deutlich mehr als <code>docker run nextcloud</code>.</p>



<p class="wp-block-paragraph"><strong>Lösung:</strong> Ein Compose-Projekt mit Nextcloud, PostgreSQL, Redis und einem Cron-Container, veröffentlicht über Traefik, Anmeldung über Authentik, Dokumente im Browser mit dem eingebauten Collabora-Server und ein nächtliches Backup im Wartungsmodus.</p>



<p class="wp-block-paragraph"><strong>Getestet mit:</strong> Nextcloud 35 (offizielles Image <code>nextcloud:35</code>, PHP 8.5), PostgreSQL 16, Redis, Traefik 3.7, Authentik, Docker Compose auf einem kleinen Heimserver</p>
</div>



<p class="wp-block-paragraph">Nextcloud ist der Dienst in meinem Homelab, den die ganze Familie wirklich nutzt: Dateien, Kalender, Kontakte, Notizen und seit Kurzem auch Office-Dokumente. Deshalb ist es auch der Dienst, den ich am wenigsten kaputt machen möchte. Dieser Artikel geht meine Konfiguration durch: die Container, die Einstellungen, auf die es hinter einem Reverse Proxy ankommt, Single Sign-on, Nextcloud Office, die Pflege nach Updates und wie ich alles sichere. Er baut auf meinem <a href="/de/traefik-reverse-proxy-docker-konfiguration/">Traefik-Artikel</a> auf; wenn du noch keinen Reverse Proxy hast, fang dort an.</p>



<h2 class="wp-block-heading">1. Der Stack im Überblick</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Container</th><th>Image</th><th>Aufgabe</th></tr></thead><tbody><tr><td><code>nextcloud</code></td><td><code>nextcloud:35</code></td><td>Die Web-App (Apache + PHP)</td></tr><tr><td><code>postgres</code></td><td><code>postgres:16</code></td><td>Die Datenbank. Deutlich besser als SQLite, sobald mehr als eine Person synchronisiert</td></tr><tr><td><code>redis</code></td><td><code>redis</code></td><td>Datei-Sperren und gemeinsamer Cache</td></tr><tr><td><code>nextcloud-cron</code></td><td><code>nextcloud:35</code></td><td>Führt alle 5 Minuten die Hintergrundjobs aus</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Das Tag <code>nextcloud:35</code> ist eine bewusste Entscheidung. Es folgt den Minor- und Patch-Versionen von Version 35, mein Update-Skript (es führt dreimal pro Woche <code>docker compose pull &amp;&amp; docker compose up -d</code> aus) holt Sicherheitsupdates also von selbst. Eine neue Hauptversion kommt dagegen nie überraschend. Nextcloud kann immer nur eine Hauptversion auf einmal springen, und vor diesem Schritt möchte ich die Release Notes lesen und ein frisches Backup haben.</p>



<h2 class="wp-block-heading">2. Die .env-Datei</h2>



<p class="wp-block-paragraph">Alles, was sich von Installation zu Installation unterscheidet, und jedes Passwort steht in einer <code>.env</code>-Datei neben der Compose-Datei. Mach sie nur für ihren Besitzer lesbar: <code>chmod 600 .env</code>.</p>



<pre class="wp-block-code"><code>NEXTCLOUD_IMAGE_TAG=nextcloud:35
NEXTCLOUD_HOSTNAME=cloud.example.com
NEXTCLOUD_URL=https://cloud.example.com
NEXTCLOUD_TIMEZONE=Europe/Berlin

NEXTCLOUD_DB_NAME=nextcloud
NEXTCLOUD_DB_USER=nextcloud
NEXTCLOUD_DB_PASSWORD=bitte-aendern-lang-und-zufaellig

NEXTCLOUD_REDIS_PASSWORD=bitte-auch-aendern

# wird nur beim allerersten Start gelesen
NEXTCLOUD_ADMIN_USERNAME=admin
NEXTCLOUD_ADMIN_PASSWORD=bitte-ebenfalls-aendern</code></pre>



<p class="wp-block-paragraph">Gute Passwörter erzeugst du zum Beispiel mit <code>openssl rand -base64 32</code>. Admin-Benutzer und -Passwort liest Nextcloud nur, wenn es sich beim ersten Start installiert. Sie später in der <code>.env</code> zu ändern, bewirkt nichts.</p>



<h2 class="wp-block-heading">3. Datenbank, Redis und Cron</h2>



<pre class="wp-block-code"><code>networks:
  nextcloud-network:
  proxy:
    external: true

volumes:
  nextcloud-app:
  nextcloud-postgres:
  redis-data:

services:
  postgres:
    image: postgres:16
    restart: unless-stopped
    volumes:
      - nextcloud-postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ${NEXTCLOUD_DB_NAME}
      POSTGRES_USER: ${NEXTCLOUD_DB_USER}
      POSTGRES_PASSWORD: ${NEXTCLOUD_DB_PASSWORD}
    networks:
      - nextcloud-network
    healthcheck:
      test: ["CMD", "pg_isready", "-q", "-d", "${NEXTCLOUD_DB_NAME}", "-U", "${NEXTCLOUD_DB_USER}"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 60s

  redis:
    image: redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "${NEXTCLOUD_REDIS_PASSWORD}"]
    volumes:
      - redis-data:/data
    networks:
      - nextcloud-network
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

  nextcloud-cron:
    image: ${NEXTCLOUD_IMAGE_TAG}
    restart: unless-stopped
    entrypoint: /cron.sh
    volumes:
      - nextcloud-app:/var/www/html
      - /srv/nextcloud/data:/var/www/html/data
    networks:
      - nextcloud-network</code></pre>



<p class="wp-block-paragraph">Ein paar Details, die man leicht übersieht:</p>



<ul class="wp-block-list">
<li><strong>Datenbank und Redis hängen nur im <code>nextcloud-network</code>.</strong> Sie haben keine veröffentlichten Ports, und Traefik sieht sie nicht. Nur Nextcloud spricht mit ihnen.</li>



<li><strong>Die Healthchecks sind hier wichtig.</strong> Nextcloud wartet auf <code>service_healthy</code> (siehe unten) und startet seine Installation oder sein Upgrade deshalb nie gegen eine Datenbank, die noch hochfährt.</li>



<li><strong>Der Cron-Container</strong> nutzt dasselbe Image und dieselben Volumes wie Nextcloud, startet aber <code>/cron.sh</code>, das alle 5 Minuten <code>cron.php</code> aufruft. Stell Nextcloud nach dem ersten Start von AJAX auf Cron um: <code>occ background:cron</code> (oder Verwaltungseinstellungen → Grundeinstellungen).</li>
</ul>



<h2 class="wp-block-heading">4. Der Nextcloud-Container</h2>



<pre class="wp-block-code"><code>  nextcloud:
    image: ${NEXTCLOUD_IMAGE_TAG}
    restart: unless-stopped
    volumes:
      - nextcloud-app:/var/www/html
      - /srv/nextcloud/data:/var/www/html/data
    environment:
      TZ: ${NEXTCLOUD_TIMEZONE}
      POSTGRES_HOST: postgres
      POSTGRES_DB: ${NEXTCLOUD_DB_NAME}
      POSTGRES_USER: ${NEXTCLOUD_DB_USER}
      POSTGRES_PASSWORD: ${NEXTCLOUD_DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_HOST_PORT: 6379
      REDIS_HOST_PASSWORD: ${NEXTCLOUD_REDIS_PASSWORD}
      NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USERNAME}
      NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
      NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_HOSTNAME}
      # hinter dem Reverse Proxy
      OVERWRITEHOST: ${NEXTCLOUD_HOSTNAME}
      OVERWRITEPROTOCOL: https
      OVERWRITECLIURL: ${NEXTCLOUD_URL}
      TRUSTED_PROXIES: 172.16.0.0/12
      # große Uploads und Office
      PHP_MEMORY_LIMIT: 8G
      PHP_UPLOAD_LIMIT: 8G
    networks:
      - nextcloud-network
      - proxy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80/"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 90s
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy</code></pre>



<p class="wp-block-paragraph"><strong>Zwei Speicherorte.</strong> Das benannte Volume <code>nextcloud-app</code> enthält die Anwendung selbst, samt <code>config/config.php</code> und den Apps, die du aus dem App-Store installierst (<code>custom_apps</code>). Die Dateien der Benutzer liegen in einem Bind-Mount auf einer großen Platte (hier <code>/srv/nextcloud/data</code>). Durch die Trennung kann ich die Daten auf eine eigene SSD legen und beides unterschiedlich sichern. Vergiss nur das Volume nicht: <code>config.php</code> enthält das Secret und den Passwort-Salt der Instanz, und ohne sie wird eine Wiederherstellung nur aus den Daten deutlich schwieriger.</p>



<p class="wp-block-paragraph"><strong>Die Reverse-Proxy-Einstellungen.</strong> Nextcloud bekommt von Traefik nur einfaches HTTP auf Port 80. Ohne diese vier Variablen baut es falsche Links, lehnt Anmeldungen über den „nicht vertrauenswürdigen“ Hostnamen ab oder zeigt jeden Besucher mit der IP-Adresse von Traefik:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Variable</th><th>Wozu</th></tr></thead><tbody><tr><td><code>OVERWRITEHOST</code></td><td>Der Hostname, den Nextcloud in Links und Weiterleitungen verwendet</td></tr><tr><td><code>OVERWRITEPROTOCOL=https</code></td><td>Links beginnen mit https, obwohl die Anfrage innerhalb von Docker http ist. Ohne diese Variable laufen Anmelde-Weiterleitungen im Kreis oder brechen ab</td></tr><tr><td><code>OVERWRITECLIURL</code></td><td>Richtige Links in E-Mails und in allem, was Hintergrundjobs oder <code>occ</code> erzeugen</td></tr><tr><td><code>TRUSTED_PROXIES</code></td><td>Nextcloud glaubt den <code>X-Forwarded-For</code>-Header nur von diesen Adressen. Dann zeigen Log, Brute-Force-Schutz und „Letzte Anmeldung“ die echte IP des Clients</td></tr></tbody></table></figure>



<p class="wp-block-paragraph"><code>172.16.0.0/12</code> deckt die Standard-Netzbereiche von Docker ab, in denen Traefik läuft. Trag ein, was <code>docker network inspect proxy</code> auf deinem Host anzeigt, und nicht mehr: Jeder Bereich, dem du vertraust, kann Client-IPs fälschen.</p>



<h2 class="wp-block-heading">5. Veröffentlichen über Traefik</h2>



<pre class="wp-block-code"><code>    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.nextcloud.rule=Host(`cloud.example.com`)"
      - "traefik.http.routers.nextcloud.entrypoints=websecure"
      - "traefik.http.routers.nextcloud.tls.certresolver=letsencrypt"
      - "traefik.http.routers.nextcloud.middlewares=nextcloud-dav,nextcloud-wellknown,nextcloud-compress,nextcloud-hsts"
      - "traefik.http.services.nextcloud.loadbalancer.server.port=80"

      # CalDAV/CardDAV-Erkennung für Handys und Kalender-Apps
      - "traefik.http.middlewares.nextcloud-dav.redirectregex.permanent=true"
      - "traefik.http.middlewares.nextcloud-dav.redirectregex.regex=https?://([^/]*)/.well-known/(card|cal)dav"
      - "traefik.http.middlewares.nextcloud-dav.redirectregex.replacement=https://$${1}/remote.php/dav/"

      # alle anderen .well-known-URLs (webfinger, nodeinfo, ...)
      - "traefik.http.middlewares.nextcloud-wellknown.redirectregex.permanent=true"
      - "traefik.http.middlewares.nextcloud-wellknown.redirectregex.regex=https?://([^/]*)(/.well-known[^#]*)"
      - "traefik.http.middlewares.nextcloud-wellknown.redirectregex.replacement=https://$${1}/index.php$${2}"

      - "traefik.http.middlewares.nextcloud-compress.compress=true"
      - "traefik.http.middlewares.nextcloud-hsts.headers.stsSeconds=15552000"</code></pre>



<p class="wp-block-paragraph">Die beiden Weiterleitungen beheben die häufigste Warnung in der Admin-Übersicht („Dein Webserver ist nicht richtig eingerichtet, um /.well-known/caldav aufzulösen“). Außerdem finden Handys damit Kalender und Kontakte, wenn du nur den Hostnamen eingibst. Das <code>$$</code> ist nötig, weil Compose <code>${1}</code> sonst als Variable liest.</p>



<p class="wp-block-paragraph">HSTS (die Zeile mit <code>stsSeconds</code>) sagt Browsern, dass sie diesen Host die nächsten 180 Tage nur noch über HTTPS aufrufen sollen. Das beseitigt eine weitere Warnung. Bei mir setzt eine allgemeine Security-Header-Middleware am Entrypoint <code>websecure</code> HSTS ohnehin für jeden Dienst (siehe den <a href="/de/traefik-absichern-geoblock-crowdsec-sso/">Artikel zum Absichern</a>). Die Zeile bleibt trotzdem hier, damit der Nextcloud-Stack HSTS auch dann bekommt, wenn ich daran einmal etwas ändere.</p>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Achtung, nur eine Middlewares-Zeile pro Router:</strong> Setzt eine Compose-Datei <code>traefik.http.routers.nextcloud.middlewares</code> zweimal, gibt es keinen Fehler. Die zweite Zeile ersetzt die erste stillschweigend. Genau das stand lange in meiner eigenen Datei: Eine frühere Zeile mit einer Buffering-Middleware war nie aktiv, und das ist mir erst beim Schreiben dieses Artikels aufgefallen (<code>docker inspect</code> zeigt die Labels, die der Container wirklich hat). Schreib alle Middlewares kommagetrennt in eine Zeile.</p>
</div>



<p class="wp-block-paragraph">Für große Uploads brauchst du übrigens keine Buffering-Middleware. Traefik hat standardmäßig keine Größenbegrenzung für Anfragen, und die Nextcloud-Clients laden große Dateien ohnehin in Stücken hoch.</p>



<h2 class="wp-block-heading">6. Anmeldung mit Authentik</h2>



<p class="wp-block-paragraph">Wie die meisten meiner Dienste nutzt Nextcloud Single Sign-on mit Authentik. Ich verwende die offizielle App <strong>OpenID Connect user backend</strong> (<code>user_oidc</code>). Die Einrichtung auf der Authentik-Seite (ein OAuth2/OpenID-Provider plus eine Anwendung) ist dieselbe wie in meinem <a href="/de/wordpress-sso-mit-authentik/">Artikel zu WordPress-SSO</a>. Die Redirect-URI für Nextcloud ist <code>https://cloud.example.com/apps/user_oidc/code</code>.</p>



<p class="wp-block-paragraph">Danach im Nextcloud-Container:</p>



<pre class="wp-block-code"><code>alias occ='docker compose exec -u www-data nextcloud php occ'

occ app:install user_oidc

occ user_oidc:provider Authentik \
  --clientid="&lt;Client-ID aus Authentik&gt;" \
  --clientsecret="&lt;Client-Secret aus Authentik&gt;" \
  --discoveryuri="https://id.example.com/application/o/nextcloud/.well-known/openid-configuration" \
  --scope="openid email profile" \
  --mapping-uid=preferred_username \
  --mapping-display-name=name \
  --mapping-email=email \
  --unique-uid=0</code></pre>



<p class="wp-block-paragraph">Entscheidend sind die letzten beiden Optionen. <code>--mapping-uid=preferred_username</code> zusammen mit <code>--unique-uid=0</code> macht den Nextcloud-Benutzernamen gleich dem Authentik-Benutzernamen statt eines langen Hashes. Ein Authentik-Benutzer <code>anna</code> landet so im bestehenden Nextcloud-Konto <code>anna</code> mit all ihren Dateien, und die Pfade bleiben lesbar.</p>



<p class="wp-block-paragraph">Die Anmeldeseite zeigt jetzt einen Button „Log in with Authentik“. Die normale Nextcloud-Anmeldung bleibt darunter verfügbar. Die behalte ich mit Absicht: Wenn Authentik ausfällt, funktioniert das lokale Admin-Konto trotzdem. Desktop- und Handy-Clients melden sich über den Browser an und nutzen damit ebenfalls SSO.</p>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Setz die Forward-Auth-Middleware von Authentik nicht vor Nextcloud. Sie bricht die Sync-Clients und WebDAV, die sich nicht bei jeder Anfrage über den Browser anmelden können. Nimm stattdessen die OIDC-App.</p>
</div>



<h2 class="wp-block-heading">7. Nextcloud Office ohne zusätzlichen Container</h2>



<p class="wp-block-paragraph">Um .docx-, .xlsx- und .odt-Dateien im Browser zu öffnen, nutze ich Nextcloud Office mit dem <strong>eingebauten CODE-Server</strong> (Collabora Online Development Edition). Er läuft im Nextcloud-Container: kein zusätzlicher Container, kein zusätzlicher Hostname, kein zusätzliches Zertifikat. Für einen Haushalt mit einer Handvoll Benutzer reicht das völlig.</p>



<pre class="wp-block-code"><code>occ app:install richdocumentscode   # der eingebaute Collabora-Server (auf ARM: richdocumentscode_arm64)
occ app:install richdocuments       # die App Nextcloud Office
occ richdocuments:activate-config</code></pre>



<p class="wp-block-paragraph">Danach sollte unter Verwaltungseinstellungen → Office stehen, dass der eingebaute Server verwendet wird. Die WOPI-URL zeigt auf <code>https://cloud.example.com/custom_apps/richdocumentscode/proxy.php?req=</code>.</p>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Achtung, Security-Header:</strong> Der Editor läuft in einem iframe innerhalb von Nextcloud. Ein globaler Header <code>X-Frame-Options: DENY</code> oder <code>SAMEORIGIN</code> vom Reverse Proxy macht daraus ein leeres, graues Dokument. Nextcloud setzt seine eigenen Frame-Header, überlass diesen Header also der App.</p>
</div>



<p class="wp-block-paragraph">Prüf auch, ob die Version der Office-App zu deiner Nextcloud-Hauptversion passt. Nach einem Upgrade bleibt manchmal eine ältere, nicht kompatible Version installiert; <code>occ app:update --all</code> behebt das.</p>



<h2 class="wp-block-heading">8. Saubere Admin-Übersicht nach einem Update</h2>



<p class="wp-block-paragraph">Nach einem Hauptversions-Upgrade zeigt Verwaltungseinstellungen → Übersicht meistens ein paar neue Warnungen. Diese Befehle habe ich gebraucht, ungefähr in dieser Reihenfolge (mit frischem Backup):</p>



<pre class="wp-block-code"><code># neue Datenbank-Indizes, die mit dem Upgrade kamen (dauert Sekunden bis Minuten)
occ db:add-missing-indices

# nur wenn die Übersicht es vorschlägt; kann bei großen Instanzen dauern
occ maintenance:repair --include-expensive

# Telefonnummern in Profilen ohne Ländervorwahl
occ config:system:set default_phone_region --value="DE"

# aufwendige Hintergrundjobs nachts ausführen (Stunde in UTC, 1 = 01:00-05:00)
occ config:system:set maintenance_window_start --type=integer --value=1

# dieselben Prüfungen wie die Admin-Übersicht, auf der Kommandozeile
occ setupchecks</code></pre>



<p class="wp-block-paragraph">Den Speicher-Cache richtet das offizielle Image selbst ein: APCu für den lokalen Cache und Redis für die Datei-Sperren, sobald <code>REDIS_HOST</code> gesetzt ist. Prüfen kannst du das mit <code>occ config:system:get memcache.locking</code>.</p>



<h2 class="wp-block-heading">9. Backups</h2>



<p class="wp-block-paragraph">Nextcloud wird jede Nacht von einem Skript auf dem Host gesichert, nicht innerhalb des Stacks. Die Reihenfolge ist wichtig:</p>



<ol class="wp-block-list">
<li><code>occ maintenance:mode --on</code>, damit sich während der Kopie keine Datei und keine Datenbankzeile ändert</li>



<li><code>pg_dump</code> der Datenbank in eine einfache SQL-Datei (nie die laufenden Datenbankdateien kopieren)</li>



<li><code>rsync</code> des Datenordners in einen Snapshot mit Datum, mit Bandbreitenlimit, damit die Platten nutzbar bleiben</li>



<li>eine Kopie von <code>config/</code>, <code>custom_apps/</code> und <code>themes/</code> aus dem App-Volume, dazu die Compose-Datei und die <code>.env</code></li>



<li><code>occ maintenance:mode --off</code>, in einem <code>trap</code>, damit das auch passiert, wenn ein Schritt fehlschlägt</li>
</ol>



<p class="wp-block-paragraph">In einer normalen Nacht dauert der Wartungsmodus ein paar Minuten. Unveränderte Dateien sind zwischen den Snapshots per Hardlink verknüpft, 14 Tage Verlauf kosten also kaum mehr Platz als eine Kopie. Wie Snapshots, Datenbank-Dumps und die Offline-Kopie zusammenspielen, steht in <a href="/de/homelab-backup-snapshots-dumps-offline-kopie/">meinem Backup-Artikel</a>.</p>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Führ nach einer Wiederherstellung <code>occ maintenance:data-fingerprint</code> aus. Damit erfahren die Sync-Clients, dass der Server in der Zeit zurückgesprungen ist. Sie fragen dann nach, bevor sie neuere Dateien überschreiben, statt still in irgendeine Richtung zu synchronisieren.</p>
</div>



<h2 class="wp-block-heading">10. Wenn etwas nicht funktioniert</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Symptom</th><th>Häufige Ursache</th></tr></thead><tbody><tr><td>„Zugriff über eine nicht vertrauenswürdige Domain“</td><td>Der Hostname fehlt in <code>trusted_domains</code>. <code>NEXTCLOUD_TRUSTED_DOMAINS</code> wirkt nur bei der ersten Installation; später <code>occ config:system:set trusted_domains 1 --value=cloud.example.com</code></td></tr><tr><td>Die Anmeldung landet wieder auf der Anmeldeseite, oder Links beginnen mit http://</td><td><code>OVERWRITEPROTOCOL=https</code> fehlt</td></tr><tr><td>Jeder Log-Eintrag zeigt dieselbe interne IP</td><td><code>TRUSTED_PROXIES</code> enthält das Netz von Traefik nicht</td></tr><tr><td>„Dein Webserver ist nicht richtig eingerichtet, um /.well-known/caldav aufzulösen“</td><td>Die Weiterleitungs-Middlewares sind nicht aktiv. Prüf mit <code>docker inspect</code>, welche Middlewares der Router wirklich bekommen hat</td></tr><tr><td>Office-Dokumente öffnen sich als leere graue Seite</td><td>Ein <code>X-Frame-Options</code>-Header vom Proxy oder eine Office-App-Version, die nicht zur Nextcloud-Version passt</td></tr><tr><td>„Letzte Hintergrundjob-Ausführung lief vor &#8230; Stunden“</td><td>Der Cron-Container läuft nicht, oder Nextcloud steht noch auf AJAX</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Die nützlichsten Befehle für den Rest:</p>



<pre class="wp-block-code"><code># die neuesten Fehler aus dem Nextcloud-Log
occ log:tail 20

# worüber sich die Admin-Übersicht beschweren würde
occ setupchecks

# welche Labels (und damit welche Traefik-Middlewares) der Container wirklich hat
docker inspect nextcloud-nextcloud-1 --format '{{json .Config.Labels}}'</code></pre>



<h2 class="wp-block-heading">Was ich gelernt habe</h2>



<ul class="wp-block-list">
<li><strong>Die Hauptversion festlegen.</strong> Mit <code>nextcloud:35</code> bekomme ich Sicherheitsupdates automatisch, und das große Upgrade passiert, wenn ich es entscheide, mit Backup.</li>



<li><strong>Die Reverse-Proxy-Einstellungen sind nicht optional.</strong> Die meisten seltsamen Nextcloud-Probleme hinter Traefik lagen an <code>OVERWRITEPROTOCOL</code> oder <code>TRUSTED_PROXIES</code>.</li>



<li><strong>Prüfen, was wirklich läuft.</strong> Ein Label, das von einem späteren überschrieben wird, erzeugt keinen Fehler. <code>docker inspect</code> zeigt die Wahrheit, die Compose-Datei nur die Absicht.</li>



<li><strong>Das App-Volume gehört ins Backup.</strong> Lange habe ich nur den Datenordner und die Datenbank gesichert. Ohne <code>config.php</code> ist eine Wiederherstellung deutlich schwieriger.</li>



<li><strong>SSO über OIDC, nicht über Forward-Auth.</strong> Die Sync-Clients funktionieren weiter, und die lokale Anmeldung ist mein Notausgang.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Meinen Docker-Host mit Home Assistant überwachen: Container, Platten, HTTP-Prüfungen und Alarme</title>
		<link>https://selfhostjournal.eu/de/docker-host-ueberwachen-home-assistant/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate></pubDate>
				<category><![CDATA[Home Assistant]]></category>
		<category><![CDATA[Homelab]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=152</guid>

					<description><![CDATA[Home Assistant überwacht den Docker-Host wie den Rest des Hauses: Portainer- und SSH-Integration, ein Alarm für gestoppte Container, HTTP-Prüfungen auf die richtige Antwort, Plattenplatz, Backups und ein Server-Dashboard.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">Kurz gesagt</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Ein Container, der still und leise stoppt, eine Platte, die vollläuft, ein Dienst, der die falsche Seite liefert: Auf einem Heimserver merkt man das meist erst, wenn jemand aus der Familie sagt „es geht nicht“.</p>



<p class="wp-block-paragraph"><strong>Lösung:</strong> Home Assistant überwacht den Docker-Host so, wie es auch den Rest des Hauses überwacht: eine Entität pro Container, Host-Werte, HTTP-Prüfungen von außen, den Backup-Status und eine Handvoll Automationen, die eine Telegram-Nachricht schicken, wenn etwas nicht stimmt.</p>



<p class="wp-block-paragraph"><strong>Getestet mit:</strong> Home Assistant 2026.10, der Portainer-Integration, meinem eigenen Add-on SSH Linux Management (MQTT), etwa 25 Containern auf einem Docker-Host, Telegram und der Companion-App für Benachrichtigungen</p>
</div>



<p class="wp-block-paragraph">Home Assistant weiß schon, wie es mir sagt, dass ein Fenster offen oder eine Batterie leer ist. Statt also ein eigenes Monitoring für meinen Server zu betreiben, lasse ich Home Assistant auch den überwachen. Alles landet an einem Ort: ein Dashboard, ein Benachrichtigungskanal und Automationen, die ich ohnehin schreiben kann. Dieser Artikel zeigt die zwei Integrationen, mit denen ich den Docker-Host in Home Assistant bringe, die Alarme, die darauf aufbauen, und was ich dabei gelernt habe.</p>



<h2 class="wp-block-heading">1. Zwei Integrationen, zwei Aufgaben</h2>



<p class="wp-block-paragraph">Home Assistant läuft in einer VM, die Container auf dem Host daneben. Um in den Host hineinzuschauen, nutze ich zwei Integrationen, weil jede in etwas anderem gut ist:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th></th><th>Portainer-Integration</th><th>SSH Linux Management (Add-on)</th></tr></thead><tbody><tr><td>Verbindet sich über</td><td>Die API von Portainer mit einem Zugriffstoken</td><td>Eine SSH-Verbindung zum Host</td></tr><tr><td>Pro Container</td><td>Status, CPU, Speicher, Neustart-Zähler, Out-of-Memory, Image-Updates, Buttons zum Neustarten/Pausieren/Neuerstellen</td><td>Läuft an/aus als Schalter, Neustart-Button</td></tr><tr><td>Wie schnell</td><td>In festen Abständen abgefragt</td><td>Vom Docker-Event-Stream geschoben, sofort</td></tr><tr><td>Host</td><td>–</td><td>CPU, RAM, Plattenbelegung pro Mountpoint, libvirt-VMs</td></tr><tr><td>Extra</td><td>–</td><td>Liest die Statusdatei meines <a href="/de/homelab-backup-snapshots-dumps-offline-kopie/">Backup-Setups</a></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Für die Alarme unten nutze ich die Status-Sensoren von Portainer für die Container und das SSH-Add-on für alles rund um den Host. Wenn du nur eine willst, fang mit Portainer an: Das ist eine eingebaute Integration und liefert pro Container am meisten.</p>



<h2 class="wp-block-heading">2. Die Portainer-Integration</h2>



<p class="wp-block-paragraph">Läuft Portainer schon auf deinem Docker-Host, ist das der schnellste Einstieg:</p>



<ol class="wp-block-list">
<li>In Portainer dein Benutzerprofil öffnen → <strong>Access tokens</strong> → <strong>Add access token</strong>, und den Token kopieren.</li>



<li>In Home Assistant: <strong>Einstellungen → Geräte &amp; Dienste → Integration hinzufügen → Portainer</strong>, die Adresse von Portainer und den Token eintragen.</li>



<li>Home Assistant legt ein Gerät pro Container an, mit Entitäten wie <code>binary_sensor.&lt;container&gt;_status</code>, <code>sensor.&lt;container&gt;_memory_usage</code>, <code>sensor.&lt;container&gt;_restart_count</code> und <code>update.&lt;container&gt;_image_update_available</code>.</li>
</ol>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Der Token kann, was sein Benutzer kann:</strong> Mit dem Token eines Admins kann Home Assistant jeden Container stoppen und neu erstellen. Leg für Home Assistant einen eigenen Portainer-Benutzer an, wenn deine Edition Rollen unterstützt, und behandle den Token wie ein Passwort: Er liegt in der Konfiguration von Home Assistant, also auch in dessen Backups.</p>
</div>



<p class="wp-block-paragraph">Eine Sache macht Portainer nicht: Löschst du einen Container, bleibt sein Gerät in Home Assistant als „nicht verfügbar“ stehen. Entferne es von Hand (<strong>Einstellungen → Geräte</strong>, das Gerät, <strong>Löschen</strong>), sonst füllen sich deine Dashboards und Automationen langsam mit toten Entitäten.</p>



<h2 class="wp-block-heading">3. Das SSH-Add-on: Host-Werte ohne root</h2>



<p class="wp-block-paragraph">Für den Host selbst (Platte, CPU, RAM, VMs) habe ich ein kleines Home-Assistant-Add-on geschrieben, <a href="https://github.com/apps-linux/ha-ssh-mange-secure" target="_blank" rel="noopener">SSH Linux Management</a>. Es öffnet eine ausgehende SSH-Verbindung zum Host und veröffentlicht, was es findet, per MQTT-Discovery, sodass die Entitäten ohne YAML in Home Assistant erscheinen:</p>



<ul class="wp-block-list">
<li><strong>Docker:</strong> Der entfernte Docker-Socket wird durch die SSH-Verbindung weitergeleitet. Es wird kein Docker-TCP-Port geöffnet, und Änderungen an Containern kommen sofort über den Docker-Event-Stream an.</li>



<li><strong>Host-Werte:</strong> CPU, Speicher und Plattenbelegung pro Mountpoint (ich beobachte <code>/</code> und die Datenplatte von Nextcloud).</li>



<li><strong>VMs:</strong> libvirt-VMs als Schalter mit Neustart-Button, über <code>virsh</code> auf derselben Verbindung.</li>



<li><strong>Das SSH-Konto ist nicht root:</strong> Es ist ein normaler Benutzer in der Gruppe <code>docker</code> (und <code>libvirt</code>), und der Host-Schlüssel wird bei der ersten Verbindung festgehalten.</li>
</ul>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Sei ehrlich zur Gruppe <code>docker</code>:</strong> Wer mit dem Docker-Socket sprechen kann, kann einen Container starten, der das Root-Dateisystem des Hosts einbindet. Mitglied dieser Gruppe zu sein heißt praktisch, root auf diesem Rechner zu sein. „Nicht root“ bedeutet hier: kein Root-Passwort in Home Assistant, ein eigener Schlüssel, den du sperren kannst, und nichts anderes, wo sich dieses Konto anmelden kann. Wenn du echte Grenzen willst, setz einen Docker-Socket-Proxy davor, der nur die API-Aufrufe erlaubt, die du brauchst.</p>
</div>



<p class="wp-block-paragraph">Eine Lehre aus dem Betrieb: Die ersten Versionen haben jede Docker-Entität nach der ID des Containers benannt. Jedes <code>docker compose up</code> nach einem Update erzeugt aber einen neuen Container mit neuer ID, also bekam Home Assistant jedes Mal eine neue Entität, und nach ein paar Wochen hatte ich <code>switch.…_traefik</code>, <code>…_traefik_2</code> bis <code>…_traefik_5</code>. Version 0.11.0 benennt die Entitäten nach dem <em>Namen</em> des Containers und entfernt Discovery-Einträge von Containern, die es nicht mehr gibt. Wenn du selbst MQTT-Discovery schreibst: Nimm für die <code>unique_id</code> etwas, das ein Neuerstellen überlebt.</p>



<h2 class="wp-block-heading">4. Alarm: ein Container ist gestoppt</h2>



<p class="wp-block-paragraph">Die wichtigste Automation ist auch die einfachste: Ist der Status-Sensor eines Containers zwei Minuten lang aus, kommt eine Nachricht mit seinem Namen. Die zwei Minuten verhindern Fehlalarme bei normalen Neustarts (Updates, Neuerstellen):</p>



<pre class="wp-block-code"><code>alias: System - Docker - Container Down Alert
description: Ein überwachter Container läuft 2 Minuten nicht → Telegram + Push.
mode: parallel
max: 10
triggers:
  - trigger: state
    entity_id:
      - binary_sensor.traefik_status
      - binary_sensor.crowdsec_status
      - binary_sensor.authentik_server_status
      - binary_sensor.nextcloud_nextcloud_1_status
      - binary_sensor.vaultwarden_status
      - binary_sensor.zigbee2mqtt_status
      # … eine Zeile pro Container, der dir wichtig ist
    to: "off"
    for:
      minutes: 2
actions:
  - action: telegram_bot.send_message
    data:
      title: "🔴 Docker-Container gestoppt"
      message: "{{ trigger.to_state.attributes.friendly_name }} läuft seit 2 Minuten nicht."
  - action: notify.mobile_app_dein_handy
    data:
      title: "🔴 Docker-Container gestoppt"
      message: "{{ trigger.to_state.attributes.friendly_name }} läuft seit 2 Minuten nicht."</code></pre>



<ul class="wp-block-list">
<li><strong><code>mode: parallel</code>:</strong> Startet Docker selbst neu, stoppen mehrere Container gleichzeitig. Jeder soll seine eigene Nachricht bekommen, nicht nur der erste.</li>



<li><strong><code>to: "off"</code>, nicht „nicht an“:</strong> Ein Sensor, der <code>unavailable</code> wird (Portainer nicht erreichbar, Container gelöscht), löst nicht aus. Das ist Absicht: Es verhindert eine Flut von Alarmen, wenn Portainer neu startet, heißt aber auch, dass ein gelöschter Container still aus der Überwachung fällt.</li>
</ul>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Die Liste der Sensoren ist die Schwachstelle. Fügst du einen Container hinzu, benennst ihn um oder entfernst ihn, muss sich die Liste mit ändern, sonst wird der neue Container nie überwacht und der alte bleibt als toter Eintrag stehen. Ich prüfe sie zusammen mit dem Backup-Job jedes neuen Containers. Ein Label an den Status-Sensoren und ein Trigger auf dieses Label würden diesen Schritt sparen, um den Preis, das Label an jedem neuen Sensor zu setzen.</p>
</div>



<h2 class="wp-block-heading">5. Laufen heißt nicht funktionieren: HTTP-Prüfungen</h2>



<p class="wp-block-paragraph">Ein Container kann „laufen“, während der Dienst darin eine Fehlerseite liefert oder der Reverse Proxy davor den Verkehr ins Leere schickt. Deshalb ruft Home Assistant alle zehn Minuten auch die öffentlichen Adressen auf, so wie es ein Besucher tun würde, und prüft den HTTP-Statuscode. Die Aufrufe sind <code>shell_command</code>-Einträge in der <code>configuration.yaml</code>:</p>



<pre class="wp-block-code"><code>shell_command:
  curl_check_nextcloud: "curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://cloud.example.com/status.php"
  curl_check_traefik: "curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://traefik.example.com/dashboard/"</code></pre>



<p class="wp-block-paragraph">Die Automation führt jeden Befehl aus, liest den Statuscode aus der Antwort und vergleicht ihn mit dem, was dieser Dienst antworten sollte:</p>



<pre class="wp-block-code"><code>triggers:
  - trigger: time_pattern
    minutes: "/10"
actions:
  - action: shell_command.curl_check_traefik
    response_variable: traefik
  - if:
      - condition: template
        value_template: "{{ traefik.stdout not in ['302', '401'] }}"
    then:
      - action: telegram_bot.send_message
        data:
          title: "🔴 Prüfung Traefik-Dashboard fehlgeschlagen"
          message: "Erwartet war ein Login (302/401), gekommen ist {{ traefik.stdout }}."</code></pre>



<ul class="wp-block-list">
<li><strong>Prüf auf die <em>richtige</em> Antwort, nicht auf irgendeine.</strong> Mein Traefik-Dashboard und mein Wiki liegen hinter Single Sign-on, also müssen sie mit einer Umleitung zum Login (302) oder 401 antworten. Ein <code>200</code> hieße, dass sie plötzlich für alle offen sind. Die Prüfung erwischt einen kaputten Login, nicht nur einen kaputten Dienst.</li>



<li><strong>Plane Wartung ein.</strong> Mein nächtliches Nextcloud-Backup schaltet den Wartungsmodus ein, der mit 503 antwortet. Zwischen 02:55 und 04:30 Uhr ignoriert die Automation genau diesen Code für Nextcloud, und nur diesen.</li>
</ul>



<h2 class="wp-block-heading">6. Plattenplatz und der Host</h2>



<p class="wp-block-paragraph">Eine volle Platte ist der klassische Grund, warum ein ganzer Server auf einmal ausfällt: Datenbanken schreiben nicht mehr, Container stürzen ab, Logs, die das erklären würden, können nicht geschrieben werden. Mit den Platten-Sensoren des SSH-Add-ons ist das ein Trigger aus zwei Zeilen:</p>



<pre class="wp-block-code"><code>triggers:
  - trigger: numeric_state
    entity_id:
      - sensor.ssh_host_myserver_host_disk_use
      - sensor.ssh_host_myserver_host_disk_use_data
    above: 85
actions:
  - action: telegram_bot.send_message
    data:
      message: "⚠️ {{ trigger.to_state.attributes.friendly_name }} ist zu {{ trigger.to_state.state }} % belegt."</code></pre>



<p class="wp-block-paragraph">Bei 85 % habe ich Tage, nicht Minuten, um zu reagieren. Dieselben Sensoren stehen als Anzeigen auf dem Dashboard, neben CPU und Speicher.</p>



<h2 class="wp-block-heading">7. Backups und Updates</h2>



<ul class="wp-block-list">
<li><strong>Backups:</strong> Ein Skript auf dem Host schreibt alle fünf Minuten den Zustand jedes Backup-Jobs in eine JSON-Datei. Das SSH-Add-on liest diese Datei und legt einen Sensor pro Job an, dazu einen Binärsensor „Backup-Problem“. Ist der zehn Minuten lang an (ein Job ist fehlgeschlagen, ein Snapshot ist älter als 26 Stunden, die Backup-Platte ist nicht eingehängt), bekomme ich eine Nachricht mit dem Grund. Die Details stehen in meinem <a href="/de/homelab-backup-snapshots-dumps-offline-kopie/">Backup-Artikel</a>.</li>



<li><strong>Updates:</strong> Eine Automation startet dreimal pro Woche um 04:00 Uhr mein Docker-Update-Skript per SSH (<code>docker compose pull</code> und <code>up -d</code> pro Stack). Der Container-Alarm oben ist das Sicherheitsnetz: Macht ein Update einen Container kaputt, weiß ich es vor dem Frühstück. Beim Reverse Proxy hält eine feste Hauptversion dieses Risiko klein (siehe meinen <a href="/de/traefik-reverse-proxy-docker-konfiguration/">Traefik-Artikel</a>).</li>



<li><strong>Versionswechsel:</strong> Für Zigbee2MQTT speichere ich zusätzlich die zuletzt bekannte Version in einem <code>input_text</code>-Helfer und schicke eine Nachricht, wenn sie sich ändert, mit der Erinnerung, die Release Notes zu lesen. Das habe ich eingebaut, nachdem ein Update das Verhalten einiger Geräte geändert hatte und ich es erst Tage später bemerkt habe.</li>
</ul>



<h2 class="wp-block-heading">8. Das Dashboard</h2>



<p class="wp-block-paragraph">Eine Ansicht „Server“ auf meinem Haupt-Dashboard zeigt die Host-Anzeigen (CPU, RAM, beide Platten), den Backup-Status und eine Kachel pro Container. Jede Kachel zeigt, ob der Container läuft, und ein Tipp auf ihr Symbol startet ihn neu:</p>



<pre class="wp-block-code"><code>type: tile
entity: switch.ssh_host_myserver_docker_vaultwarden
name: Vaultwarden
icon: mdi:restart
icon_tap_action:
  action: perform-action
  perform_action: button.press
  target:
    entity_id: button.ssh_host_myserver_docker_vaultwarden_restart</code></pre>



<p class="wp-block-paragraph">Das ist nicht für den täglichen Gebrauch gedacht. Es ist die Seite, die ich öffne, wenn eine Nachricht kommt, um auf einen Blick zu sehen, ob ein einzelner Container ausgefallen ist oder der ganze Host ein Problem hat, und um den offensichtlichen Kandidaten vom Handy aus neu zu starten.</p>



<h2 class="wp-block-heading">Was ich gelernt habe</h2>



<ul class="wp-block-list">
<li><strong>Ein Ort für Alarme.</strong> Telegram lese ich sowieso. Ein eigenes Monitoring mit eigener App wäre noch etwas, das ich ignoriere.</li>



<li><strong>Prüf auch von außen.</strong> „Container läuft“ und „Dienst funktioniert für einen Besucher“ sind zwei verschiedene Fragen. Ein Container kann einwandfrei laufen, während sein Login kaputt ist oder der Proxy woandershin zeigt.</li>



<li><strong>Prüf die erwartete Antwort.</strong> Eine Login-Seite, die plötzlich 200 liefert, ist ein Sicherheitsproblem, kein Erfolg.</li>



<li><strong>Gib jedem Alarm eine kurze Verzögerung.</strong> Zwei Minuten für Container, zehn für Backups: Echte Probleme bleiben, Neustarts wecken dich nicht.</li>



<li><strong>Räum auf, wenn du einen Container entfernst.</strong> Gerät löschen, aus der Alarmliste und vom Dashboard nehmen. Tote Entitäten machen ein Dashboard, dem du nicht mehr traust.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>So sichere ich mein Homelab: Snapshots, Datenbank-Dumps und eine Offline-Kopie</title>
		<link>https://selfhostjournal.eu/de/homelab-backup-snapshots-dumps-offline-kopie/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 15:42:25 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=145</guid>

					<description><![CDATA[Nächtliche rsync-Snapshots jedes Dienstes, Datenbanken als geprüfte Dumps, eine dritte Kopie, die sich ein Laptop auf eine verschlüsselte SSD holt, Alarme in Home Assistant und ein getestetes Restore-Skript.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">Kurz gesagt</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> In einem Homelab sammeln sich Dinge, die man nicht neu herunterladen kann: Dokumente, Passwörter, Fotos, jahrelange Sensordaten, die Kopplung jedes Zigbee-Geräts. Eine kaputte Platte, ein schiefgegangenes Update oder ein Ransomware-Angriff, und alles ist weg.</p>



<p class="wp-block-paragraph"><strong>Lösung:</strong> Nächtliche Snapshots jedes Dienstes auf eine eigene Platte (Datenbanken als Dumps, Dateien mit rsync-Hardlinks), eine dritte Kopie, die sich ein Laptop auf eine verschlüsselte SSD holt, eine Überwachung, die Alarm schlägt, wenn etwas fehlt, und ein getestetes Restore-Skript.</p>



<p class="wp-block-paragraph"><strong>Getestet mit:</strong> etwa zehn Diensten in Docker (Nextcloud, Vaultwarden, Paperless-ngx, Authentik, BookStack, Zigbee2MQTT, Traefik, PostgreSQL für Home Assistant), zwei WordPress-Seiten bei einem Webhoster, Bash + rsync + systemd-Timern, Home Assistant für die Alarme</p>
</div>



<p class="wp-block-paragraph">Backups sind der langweiligste Teil eines Homelabs, bis zu dem Tag, an dem man eins braucht. Ich habe meins Schritt für Schritt aufgebaut, meist nach „was wäre wenn…“-Momenten, und am Ende sind es einfache Bash-Skripte, rsync und systemd-Timer geworden: keine Backup-Software mit eigener Datenbank, die ich erst wiederherstellen müsste. Dieser Artikel zeigt das ganze Setup: was ich sichere, wie die Snapshots funktionieren, warum Datenbanken gedumpt statt kopiert werden, wie die dritte Kopie gegen einen gehackten Server geschützt ist, woran ich jede Nacht erkenne, dass es geklappt hat, und wie eine Wiederherstellung aussieht.</p>



<h2 class="wp-block-heading">1. Drei Kopien, zwei Platten, eine davon außer Reichweite</h2>



<p class="wp-block-paragraph">Die klassische Regel heißt 3-2-1: drei Kopien deiner Daten, auf zwei verschiedenen Medien, eine davon außer Haus. So sieht meine Version aus, und hier hat sie ihre Grenzen:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Kopie</th><th>Wo</th><th>Schützt vor</th></tr></thead><tbody><tr><td>1. Live-Daten</td><td>Die SSD des Servers</td><td>nichts – das ist, was wir schützen</td></tr><tr><td>2. Nächtliche Snapshots</td><td>Eine USB-Platte am Server, 14–30 Tage Verlauf</td><td>gelöschte oder überschriebene Dateien, kaputte Updates, eine defekte Server-SSD</td></tr><tr><td>3. Geholte Kopie</td><td>Eine verschlüsselte SSD, die ein Laptop selbst befüllt</td><td>eine defekte Backup-Platte, einen gehackten Server, Ransomware, Fehler mit den Snapshots</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Ehrlich gesagt steht die dritte Kopie im selben Haus. Gegen Feuer oder Einbruch hilft sie nicht; dafür müsste die SSD woanders liegen, oder eine verschlüsselte Cloud-Kopie wäre der nächste Schritt. Für alles andere haben diese drei Ebenen bisher jedes echte Problem abgedeckt, das ich hatte.</p>



<h2 class="wp-block-heading">2. Was gesichert wird</h2>



<p class="wp-block-paragraph">Jeder Dienst hat sein eigenes kleines Skript und seine eigene Uhrzeit, sodass ein fehlschlagender Job die anderen nie blockiert und der schwere Nextcloud-Job zuletzt läuft:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Dienst</th><th>Was gesichert wird</th><th>Aufbewahrt</th><th>Ausfallzeit</th></tr></thead><tbody><tr><td>WordPress-Seiten (Webhoster)</td><td>Dateien per tar über SSH + MySQL-Dump</td><td>30 Tage</td><td>keine</td></tr><tr><td>Paperless-ngx</td><td>PostgreSQL-Dump + alle Dokumente + Suchindex</td><td>30 Tage</td><td>keine</td></tr><tr><td>Authentik (Single Sign-on)</td><td>PostgreSQL-Dump + Compose-Ordner inkl. <code>.env</code></td><td>30 Tage</td><td>keine</td></tr><tr><td>Infrastruktur</td><td>Traefik (inkl. Zertifikate), CrowdSec-Konfiguration, Portainer, SSH-Konfiguration, die Backup-Skripte selbst</td><td>60 Tage</td><td>Portainer ~1 s pausiert</td></tr><tr><td>Zigbee2MQTT</td><td>Der ganze Datenordner (Netzwerkschlüssel, Gerätedatenbank)</td><td>30 Tage</td><td>wenige Sekunden pausiert</td></tr><tr><td>BookStack-Wiki</td><td>MariaDB-Dump + Uploads + <code>.env</code></td><td>30 Tage</td><td>keine</td></tr><tr><td>PostgreSQL für Home Assistant</td><td>Dump jeder Datenbank + Rollen</td><td>14 Tage</td><td>keine</td></tr><tr><td>Vaultwarden</td><td>Konsistente SQLite-Kopie + Schlüssel + Anhänge</td><td>30 Tage</td><td>keine</td></tr><tr><td>Nextcloud</td><td>Alle Benutzerdateien + PostgreSQL-Dump + Config-Volume</td><td>14 Tage</td><td>Wartungsmodus während des Laufs</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Bewusst nicht gesichert: Logs, Caches, Warteschlangen, die Sperrliste von CrowdSec (die baut sich selbst neu auf) und die Live-Datenbankordner. Die sind unbrauchbar, solange die Datenbank läuft; die Dumps sind das Backup.</p>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Die eigenen Backups von Home Assistant enthalten eine externe Recorder-Datenbank <em>nicht</em>. Wenn du deinen Verlauf wie ich nach PostgreSQL oder MariaDB verlegt hast, brauchst du einen eigenen Dump davon, sonst sind Jahre an Statistiken mit dem nächsten Plattenausfall weg.</p>
</div>



<h2 class="wp-block-heading">3. Snapshots mit rsync-Hardlinks</h2>



<p class="wp-block-paragraph">Jeder Lauf legt einen Ordner mit Datum an, etwa <code>2026-10-20_0030</code>. Jeder dieser Ordner sieht aus wie eine <strong>vollständige Kopie</strong>: du kannst darin stöbern, einzelne Dateien herauskopieren oder alles wiederherstellen. Dateien, die sich seit der letzten Nacht nicht geändert haben, werden aber nicht noch einmal kopiert. Sie sind <em>Hardlinks</em> auf dieselbe Datei im vorherigen Snapshot und belegen keinen zusätzlichen Platz. Genau das macht die rsync-Option <code>--link-dest</code>:</p>



<pre class="wp-block-code"><code>/backup/paperless_backup/
├── 2026-10-19_0030/
│   ├── paperless-db.dump   Datenbank-Dump
│   ├── media/              alle Dokumente (größtenteils Hardlinks)
│   ├── data/               Suchindex, Klassifikator
│   ├── compose/            docker-compose.yml, .env
│   └── rsync.log
├── 2026-10-20_0030/
├── latest -&gt; 2026-10-20_0030
└── backup-paperless.sh    das Skript kopiert sich selbst hierher</code></pre>



<ul class="wp-block-list">
<li><strong>Einen alten Snapshot zu löschen ist sicher.</strong> Eine Datei verschwindet erst von der Platte, wenn der letzte Snapshot gelöscht wird, der auf sie zeigt.</li>



<li><strong>Das Dateisystem muss Hardlinks können:</strong> ext4, XFS oder btrfs. Nicht exFAT oder NTFS – das typische Format einer neuen USB-Platte.</li>



<li><strong>Hardlinks sind keine zusätzlichen Kopien.</strong> Dreißig Snapshots eines unveränderten Dokuments sind trotzdem nur eine Datei auf der Platte. Stirbt dieser Sektor, sind alle dreißig betroffen. Dafür gibt es die dritte Kopie.</li>



<li><strong>Das Skript kopiert sich selbst</strong> neben die Snapshots, damit die Anleitung auf der Backup-Platte liegt, auch wenn der Server weg ist.</li>
</ul>



<h2 class="wp-block-heading">4. Datenbanken werden gedumpt, nie kopiert</h2>



<p class="wp-block-paragraph">Wer die Dateien einer laufenden Datenbank kopiert, bekommt ein Abbild eines bewegten Ziels: halb geschriebene Seiten, ein Write-Ahead-Log, das nicht dazu passt. Das lässt sich vielleicht wiederherstellen, vielleicht auch nicht. Ein Dump bittet die Datenbank selbst um einen konsistenten Export, während sie weiterläuft. Und jeder Dump wird direkt nach dem Schreiben geprüft:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Datenbank</th><th>Dump</th><th>Prüfung</th></tr></thead><tbody><tr><td>PostgreSQL</td><td><code>pg_dump -Fc</code> (komprimiertes Custom-Format)</td><td><code>pg_restore --list</code> muss ihn lesen können</td></tr><tr><td>MariaDB / MySQL</td><td><code>mariadb-dump</code> / <code>mysqldump</code>, gezippt</td><td><code>gzip -t</code> und die Zeile „Dump completed“ am Ende</td></tr><tr><td>SQLite (Vaultwarden)</td><td><code>sqlite3 … ".backup"</code> (konsistente Online-Kopie)</td><td><code>PRAGMA integrity_check</code></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Ein Dump, der seine Prüfung nicht besteht, stoppt den ganzen Job, und der Snapshot der Nacht wird nie zu <code>latest</code> (nächster Abschnitt). Ein kaputtes Backup ist schlimmer als keins, weil du es erst merkst, wenn du es brauchst.</p>



<h2 class="wp-block-heading">5. Ein Backup-Skript, erklärt</h2>



<p class="wp-block-paragraph">Alle meine Skripte folgen demselben Muster. Hier das für Paperless, leicht gekürzt:</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -Eeuo pipefail

PROJECT=/srv/docker/paperless          # Compose-Ordner
DEST=/backup/paperless_backup
KEEP_DAYS=30
STAMP=$(date +%F_%H%M)
SNAP=$DEST/$STAMP
WORK=$SNAP.partial

fail() { echo "ERROR: $*"; exit 1; }

# 1. nie die Systemplatte füllen, wenn die Backup-Platte fehlt
mountpoint -q /backup || fail "/backup is not mounted"

mkdir -p "$DEST" &amp;&amp; chmod 700 "$DEST"
rm -rf "$DEST"/*.partial               # Reste eines fehlgeschlagenen Laufs
mkdir -p "$WORK"

LINK=()
[ -d "$DEST/latest" ] &amp;&amp; LINK=(--link-dest="$DEST/latest/media")

# 2. Datenbank-Dump + Prüfung
cd "$PROJECT"
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB"' &gt; "$WORK/paperless-db.dump"
docker compose exec -T db pg_restore --list &lt; "$WORK/paperless-db.dump" &gt; /dev/null 
  || fail "dump check failed"

# 3. Dateien, unveränderte als Hardlinks auf den letzten Snapshot
rsync -aHAX --numeric-ids "${LINK[@]}" "$PROJECT/data/media/" "$WORK/media/"

# 4. erst jetzt wird der Snapshot echt
mv "$WORK" "$SNAP"
ln -sfn "$STAMP" "$DEST/latest"
cp -f "$0" "$DEST/"

# 5. Aufbewahrung
find "$DEST" -mindepth 1 -maxdepth 1 -type d -name '20??-??-??_????' 
  -mtime +"$KEEP_DAYS" -exec rm -rf {} +</code></pre>



<ul class="wp-block-list">
<li><strong><code>set -Eeuo pipefail</code>:</strong> jeder fehlschlagende Befehl stoppt das Skript. Ein Backup-Skript, das nach einem Fehler weitermacht, erzeugt Backups, die gut aussehen und es nicht sind.</li>



<li><strong><code>mountpoint -q</code>:</strong> ist die USB-Platte nicht eingehängt, ist <code>/backup</code> nur ein leerer Ordner auf der Systemplatte. Ohne diese Prüfung füllt die erste Nacht nach einem Plattenausfall deine System-SSD.</li>



<li><strong>Der <code>.partial</code>-Ordner:</strong> der Snapshot entsteht unter einem vorläufigen Namen und wird erst umbenannt, wenn alles geklappt hat. Ein fehlgeschlagener Lauf wird nie zu <code>latest</code>, also verlinkt der nächste Lauf weiter auf den letzten guten.</li>



<li><strong><code>rsync -aHAX --numeric-ids</code>:</strong> behält Rechte, Hardlinks, ACLs, erweiterte Attribute und die numerischen Besitzer-IDs. Container laufen oft unter Benutzer-IDs, die es auf dem Host nicht gibt; mit Namen statt Nummern würde eine Wiederherstellung die Besitzer falsch setzen.</li>



<li><strong>Aufbewahrung nach Ordneralter:</strong> <code>find -mtime +30</code> entfernt Snapshots, die älter als 30 Tage sind. Dank der Hardlinks ist das immer sicher.</li>
</ul>



<h2 class="wp-block-heading">6. Zeitplan mit systemd-Timern</h2>



<p class="wp-block-paragraph">Ich nutze systemd-Timer statt cron. Sie protokollieren jeden Lauf im Journal, holen Läufe nach, die verpasst wurden, während der Server aus war (<code>Persistent=true</code>), und ein Dienst kann warten, bis die Backup-Platte eingehängt ist:</p>



<pre class="wp-block-code"><code># /etc/systemd/system/backup-paperless.service
[Unit]
Description=Paperless backup
RequiresMountsFor=/backup
After=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-paperless.sh

# /etc/systemd/system/backup-paperless.timer
[Unit]
Description=Nightly Paperless backup

[Timer]
OnCalendar=*-*-* 00:30
Persistent=true

[Install]
WantedBy=timers.target</code></pre>



<p class="wp-block-paragraph">Eingeschaltet wird er mit <code>systemctl enable --now backup-paperless.timer</code>. <code>systemctl list-timers</code> zeigt, wann jeder Job zuletzt lief und wann er als Nächstes läuft, und <code>journalctl -u backup-paperless</code> zeigt seine Ausgabe. Meine Jobs starten zwischen 23:30 und 03:00 Uhr, mindestens 15 Minuten auseinander, damit sie sich nie um die Platte streiten.</p>



<h2 class="wp-block-heading">7. Dienste, die besondere Sorgfalt brauchen</h2>



<ul class="wp-block-list">
<li><strong>Nextcloud:</strong> der Job schaltet den Wartungsmodus ein, dumpt die Datenbank, kopiert die Dateien und schaltet ihn wieder aus. Sonst könnte jemand zwischen Dump und Dateikopie eine Datei hochladen, und beides passt nicht mehr zusammen. Die Kopie läuft mit einer Bandbreitenbegrenzung von rsync (<code>--bwlimit</code>), damit die Platten nicht ausgelastet sind, und um 03:00 Uhr, wenn niemand etwas hochlädt. In einer normalen Nacht sind das wenige Minuten Wartungsmodus.</li>



<li><strong>Zigbee2MQTT:</strong> sein Ordner enthält den Netzwerkschlüssel und die Datenbank aller gekoppelten Geräte. Ohne ihn muss jedes Gerät neu gekoppelt werden. Der Container wird für die wenigen Sekunden der Kopie pausiert (<code>docker pause</code>, nicht gestoppt), damit die Datenbankdatei konsistent ist.</li>



<li><strong>Authentik und BookStack:</strong> die Datenbank allein reicht nicht. Beide haben in ihrer <code>.env</code> einen geheimen Schlüssel, der Sitzungen signiert oder Daten verschlüsselt. Stell den Dump <em>zusammen mit</em> der <code>.env</code> aus derselben Nacht wieder her, sonst funktionieren Logins und verschlüsselte Felder nicht mehr.</li>



<li><strong>Der Reverse Proxy:</strong> die <code>acme.json</code> von Traefik enthält alle Zertifikate und den Let&#8217;s-Encrypt-Kontoschlüssel (siehe meinen <a href="/de/traefik-reverse-proxy-docker-konfiguration/">Traefik-Artikel</a>). Ohne sie fordert Traefik jedes Zertifikat neu an und kann in Ratenlimits laufen.</li>



<li><strong>Seiten beim Webhoster:</strong> der Server meldet sich mit einem SSH-Schlüssel an, holt den WordPress-Ordner als tar-Archiv und dumpt die Datenbank auf dem Host. Die Backups des Hosters sind ein Bonus, kein Plan.</li>
</ul>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Deine Backup-Platte ist voller Geheimnisse:</strong> Passwort-Tresor, Datenbank-Passwörter, Signaturschlüssel, der Zigbee-Netzwerkschlüssel. Behandle sie wie den Server selbst: physisch sicher, Ordner nur für root lesbar (<code>chmod 700</code>), und die Kopie, die den Server verlässt, verschlüsselt.</p>
</div>



<h2 class="wp-block-heading">8. Die dritte Kopie: geholt, nur lesend, verschlüsselt</h2>



<p class="wp-block-paragraph">Der naheliegende Weg zu einer zweiten Kopie: der Server schiebt sie irgendwohin. Das Problem: ist der Server gehackt oder von Ransomware befallen, hat der Angreifer denselben Zugriff und kann auch diese Kopie löschen oder verschlüsseln. Deshalb funktioniert meine dritte Kopie andersherum. Ein Laptop <strong>holt</strong> sich den neuesten Snapshot jedes Jobs auf eine verschlüsselte USB-SSD, und der Server hat keinen Weg dorthin:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Entscheidung</th><th>Warum</th></tr></thead><tbody><tr><td>Holen statt schieben</td><td>Der Server hat überhaupt keinen Zugang zum Laptop. Ein kompromittierter Server kann die Kopie nicht anfassen.</td></tr><tr><td>Nur-Lese-Schlüssel auf dem Server</td><td>Ein eigener Benutzer darf nur <code>rrsync -ro</code> auf dem Backup-Ordner ausführen. Der Laptop kann lesen, nie schreiben oder löschen.</td></tr><tr><td>Neuer Snapshot mit Datum pro Lauf, <strong>nie <code>--delete</code></strong></td><td>Verschwinden Snapshots auf dem Server (aus Versehen oder absichtlich), bleiben die älteren Kopien auf der SSD erhalten.</td></tr><tr><td>Gleiche Ordnerstruktur</td><td>Das Restore-Skript funktioniert auf der SSD unverändert.</td></tr><tr><td>LUKS-Verschlüsselung mit Schlüsseldatei</td><td>Nur während eines Laufs entsperrt, danach wieder geschlossen. Eine verlorene SSD ist unlesbar.</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Die <code>rrsync</code>-Einschränkung ist eine Zeile in der <code>authorized_keys</code> des Servers:</p>



<pre class="wp-block-code"><code>command="sudo rrsync -ro /backup",restrict ssh-ed25519 AAAA… offsite-pull</code></pre>



<p class="wp-block-paragraph">Der Job auf dem Laptop läuft stündlich, tut aber nur etwas, wenn es Sinn ergibt: höchstens einmal am Tag, nur am Netzteil, nie während der Server seine eigenen Backups macht (23:15–04:30 Uhr), und nur wenn die SSD angesteckt ist und der Server antwortet. Außerdem prüft er, was er kopiert:</p>



<ul class="wp-block-list">
<li><strong>Stichproben:</strong> Dumps nicht leer, gzip-Archive lesbar, die Vaultwarden-Datenbank besteht ihre Integritätsprüfung, die Zertifikatsdatei ist nicht leer.</li>



<li><strong>Ransomware-Alarm:</strong> vor der Nextcloud-Kopie zählt ein Probelauf, wie viele Dateien sich geändert haben. Sind es mehr als 20 %, stimmt etwas gewaltig nicht (so sieht Massenverschlüsselung aus). Die Kopie wird behalten, aber alte Generationen werden nicht mehr gelöscht, bis ich es mir angesehen habe.</li>



<li><strong>Längerer Verlauf als auf dem Server:</strong> die neuesten 7 Kopien, dann eine pro Woche für 8 Wochen und eine pro Monat für 12 Monate. Nextcloud mit etwa 800 GB behält 10 Generationen.</li>
</ul>



<h2 class="wp-block-heading">9. Wissen, dass es geklappt hat</h2>



<p class="wp-block-paragraph">Ein Backup, das vor drei Wochen still und leise aufgehört hat, ist der häufigste Weg, Daten zu verlieren. Deshalb sammelt ein kleines Python-Skript auf dem Server alle fünf Minuten den Zustand jedes Jobs und schreibt ihn in eine JSON-Datei: letzter Lauf, Ergebnis, Alter des neuesten Snapshots, ob der Timer aktiv ist, ob die Backup-Platte eingehängt ist.</p>



<ul class="wp-block-list">
<li><strong>Eine kleine Statusseite</strong> auf dem Server zeigt das als Tabelle.</li>



<li><strong>Home Assistant</strong> liest dieselbe Datei und legt pro Job einen Sensor an (<code>ok</code>, <code>failed</code>, <code>stale</code>, …). Hat ein Job zehn Minuten lang ein Problem, bekomme ich eine Push-Benachrichtigung und eine Telegram-Nachricht. „Stale“ heißt, dass der neueste Snapshot älter als 26 Stunden ist; das erwischt auch einen Timer, der einfach nie gelaufen ist.</li>



<li><strong>Abdeckungsprüfung:</strong> das Skript listet außerdem jeden <em>laufenden</em> Container auf und vergleicht ihn mit einer Liste abgedeckter Dienste. Ein neuer Container ohne Backup-Job erscheint als Warnung. Damit war die Lücke geschlossen, vor der ich am meisten Angst hatte: an einem Sonntagnachmittag einen Dienst hinzufügen und sein Backup vergessen.</li>
</ul>



<p class="wp-block-paragraph">Zur Abdeckungsliste gehört eine Hausregel: jeder neue Container bekommt seinen Backup-Job, seinen Wiederherstellungsschritt und seinen Eintrag in der Abdeckungsliste <em>in derselben Änderung</em>. „Später“ ist der Weg, auf dem Backups vergessen werden.</p>



<h2 class="wp-block-heading">10. Wiederherstellen</h2>



<p class="wp-block-paragraph">Zuerst der einfache Weg: die meisten „ich habe etwas gelöscht“-Momente lassen sich in der App selbst lösen. Nextcloud hat gelöschte Dateien und Versionen, Paperless einen Papierkorb für 30 Tage. Erst wenn das nicht hilft, gehe ich an die Snapshots, und eine einzelne Datei ist nur eine Kopie aus dem richtigen Datumsordner.</p>



<p class="wp-block-paragraph">Für den schlimmen Tag, wenn der Server weg ist oder mehrere Dienste kaputt sind, liegt ein interaktives Restore-Skript auf der Backup-Platte selbst:</p>



<ol class="wp-block-list">
<li><strong>Jobs wählen:</strong> einen, mehrere oder alle, in fester Reihenfolge: zuerst die Infrastruktur (Reverse Proxy, Zertifikate), dann Single Sign-on, dann die Datenbanken und Apps.</li>



<li><strong>Snapshot wählen:</strong> den neuesten, ein Datum für alle oder pro Job.</li>



<li><strong>Schritte pro Job wählen:</strong> Dateien, Datenbank, Container.</li>



<li><strong>Probelauf:</strong> das Skript zeigt jeden Befehl, den es ausführen würde, und macht erst weiter, wenn ich <code>RESTORE</code> eintippe.</li>
</ol>



<p class="wp-block-paragraph">Es löscht nie etwas: Dateien, die es überschreibt, wandern in einen Ordner <code>.pre-restore-&lt;Zeit&gt;</code>, und jede Datenbank wird gedumpt, bevor sie ersetzt wird. Die Backup-Timer pausieren während der Wiederherstellung, damit kein Backup-Job gegen ein halb wiederhergestelltes System läuft.</p>



<p class="wp-block-paragraph">Ein Restore-Skript, das du nie ausgeführt hast, ist eine Hoffnung, kein Plan. Meins hat einen Prüfstand: gefälschte <code>docker</code>-, <code>systemctl</code>&#8211; und <code>sshd</code>-Befehle und gefälschte Snapshots, damit es jedes Szenario durchspielen kann, ohne echte Daten anzufassen. Das sind 211 Prüfungen, und sie müssen nach jeder Änderung am Restore-Skript bestehen. Eine echte Wiederherstellung auf einer Ersatzmaschine ab und zu ersetzt das trotzdem nicht: das ist der einzige Beweis, der zählt.</p>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Halte fest, wo die Wiederherstellungsanleitung liegt, und sorg dafür, dass sie auf der Backup-Platte und auf der Offline-Kopie ist, nicht nur auf dem Server, den du vielleicht gerade wiederherstellst. Bei mir sind das eine README neben den Snapshots, eine Kopie jedes Skripts in seinem Backup-Ordner und das Restore-Skript selbst.</p>
</div>



<h2 class="wp-block-heading">Was ich gelernt habe</h2>



<ul class="wp-block-list">
<li><strong>Langweilige Werkzeuge gewinnen.</strong> rsync, Bash und systemd gibt es auch in zehn Jahren noch, und ich kann mit nichts als einer Shell wiederherstellen.</li>



<li><strong>Datenbanken dumpen und den Dump prüfen.</strong> Ein kopierter Datenbankordner ist ein Glücksspiel; ein ungeprüfter Dump eine Überraschung, die nur darauf wartet.</li>



<li><strong>Snapshots sind keine Kopien.</strong> Hardlinks geben dir Verlauf, keine Redundanz. Du brauchst trotzdem eine zweite Platte.</li>



<li><strong>Die Kopie, die einen Angriff überleben muss, holen statt schieben.</strong> Was der Server erreichen kann, kann auch ein Angreifer auf dem Server erreichen.</li>



<li><strong>Die Backups überwachen, nicht nur die Dienste.</strong> Der Alarm „seit 26 Stunden kein neuer Snapshot“ ist mehr wert als jede Funktion des Backups selbst.</li>



<li><strong>Geheimnisse gehören zu den Daten.</strong> Schlüssel und <code>.env</code>-Dateien aus derselben Nacht wie die Datenbank, sonst sieht die Wiederherstellung gut aus und nichts funktioniert.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Traefik absichern: Geo-Blocking, CrowdSec und Single Sign-on</title>
		<link>https://selfhostjournal.eu/de/traefik-absichern-geoblock-crowdsec-sso/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 13:38:02 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<category><![CDATA[Sicherheit und SSO]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=137</guid>

					<description><![CDATA[Teil 2 meines Traefik-Setups: GeoBlock für die Länder, die du brauchst, CrowdSec mit Web Application Firewall, Authentik Forward Auth vor Apps, Grundregeln für Container und wie du alles testest.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">Kurz gesagt</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Sobald Port 80 und 443 offen sind, scannen Bots aus aller Welt deine Dienste, probieren Passwörter aus und suchen nach bekannten Sicherheitslücken. Traefik allein leitet all das weiter.</p>



<p class="wp-block-paragraph"><strong>Lösung:</strong> Mehrere Schichten vor jedem Dienst: Geo-Blocking für Länder, aus denen du nie zugreifst, CrowdSec gegen bekannte Angreifer und Angriffsmuster, Single Sign-on vor Apps ohne eigenen guten Login, dazu ein paar Grundregeln für die Container.</p>



<p class="wp-block-paragraph"><strong>Getestet mit:</strong> Traefik 3.7, GeoBlock-Plugin 0.3.8, CrowdSec 1.8 mit dem Traefik-Bouncer-Plugin 1.7 und AppSec, Authentik 2026.8, Docker Compose auf einem kleinen Heimserver</p>
</div>



<p class="wp-block-paragraph">Das ist der zweite Teil meines Traefik-Setups. Der <a href="/de/traefik-reverse-proxy-docker-konfiguration/">erste Teil</a> erklärt die Grundlagen: Entrypoints, Router, Labels und Zertifikate. Hier geht es um die Frage, die Traefik selbst nicht beantwortet: Soll diese Anfrage überhaupt herein? Ein Reverse Proxy entscheidet, <em>wohin</em> eine Anfrage geht. Die Schichten in diesem Artikel entscheiden, <em>ob</em>.</p>



<h2 class="wp-block-heading">Die Schichten im Überblick</h2>



<p class="wp-block-paragraph">Jede Anfrage an Port 443 durchläuft diese Prüfungen der Reihe nach. Jede ist billig, und jede hält eine andere Art von Besucher auf:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>#</th><th>Schicht</th><th>Hält auf</th><th>Gilt für</th></tr></thead><tbody><tr><td>1</td><td>GeoBlock</td><td>Alles aus Ländern, die ich nicht erlaube</td><td>alle Dienste</td></tr><tr><td>2</td><td>CrowdSec (IP-Reputation)</td><td>IP-Adressen, die für Angriffe bekannt sind, und IPs, die sich auf meinem Server danebenbenehmen</td><td>alle Dienste</td></tr><tr><td>3</td><td>CrowdSec AppSec (WAF)</td><td>Anfragen, die wie Angriffe aussehen: SQL-Injection, Path Traversal, bekannte Exploits</td><td>alle Dienste</td></tr><tr><td>4</td><td>Single Sign-on (Authentik)</td><td>Alle, die nicht angemeldet sind</td><td>Apps ohne eigenen guten Login</td></tr><tr><td>5</td><td>Der Login der App selbst</td><td>Alle ohne Konto</td><td>jede App</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Keine Schicht ist perfekt. GeoBlock lässt sich mit einem VPN austricksen, CrowdSec kennt nicht jeden Angreifer, und jeder Login kann einen Fehler haben. Zusammen machen sie aus „jeder im Internet“ ein „wenige Leute, in wenigen Ländern, mit gültigem Login“.</p>



<h2 class="wp-block-heading">1. Eine Zeile für alle Dienste</h2>



<p class="wp-block-paragraph">Man könnte Middlewares an jeden Router einzeln hängen. Ich hänge die zwei globalen stattdessen an den Entrypoint <code>websecure</code>, in der statischen Konfiguration von Traefik. So kann kein Dienst vergessen werden, auch keiner, den ich nächstes Jahr dazunehme:</p>



<pre class="wp-block-code"><code>    command:
      # ... die Konfiguration aus Teil 1 ...

      # Schutz für JEDE Seite auf 443: zuerst GeoBlock, dann CrowdSec, dann Security-Header (Schritt 5).
      # Beides schnell abschalten: Zeile auskommentieren und docker compose up -d
      - "--entrypoints.websecure.http.middlewares=geoblock@docker,crowdsec@docker,secheaders@docker"

      # Die zwei Plugins, beim Start von plugins.traefik.io geladen
      - "--experimental.plugins.geoblock.modulename=github.com/PascalMinder/geoblock"
      - "--experimental.plugins.geoblock.version=v0.3.8"
      - "--experimental.plugins.crowdsec.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
      - "--experimental.plugins.crowdsec.version=v1.7.1"</code></pre>



<ul class="wp-block-list">
<li><strong>Die Reihenfolge zählt:</strong> GeoBlock läuft zuerst, weil es die billigste Prüfung ist. Anfragen aus gesperrten Ländern erreichen CrowdSec gar nicht erst, das hält dessen Logs und Arbeit klein.</li>



<li><strong><code>@docker</code></strong> heißt, die Middleware ist mit Docker-Labels definiert, hier am Traefik-Container selbst (nächste Abschnitte).</li>



<li><strong>Die Plugin-Versionen sind fest</strong>, aus demselben Grund wie das Traefik-Image in Teil 1: eine neue Plugin-Version kann Optionen ändern.</li>



<li><strong>Port 80 ist nicht abgedeckt,</strong> und das ist in Ordnung: Er antwortet nur mit einer Umleitung auf HTTPS, und dort greifen dann alle Prüfungen.</li>
</ul>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Der Kommentar über der Zeile ist Absicht. Wenn du dich je aussperrst (zum Beispiel auf Reisen), schalten eine auskommentierte Zeile und ein Neustart alle Filter ab. Schreib dir das irgendwo auf, wo du es auch unter Stress findest.</p>
</div>



<h2 class="wp-block-heading">2. GeoBlock: nur die Länder, die du brauchst</h2>



<p class="wp-block-paragraph">Meine Dienste nutzt meine Familie, und wir alle sind in zwei europäischen Ländern. Es gibt keinen Grund, warum jemand aus anderen Ländern meine Login-Seiten erreichen sollte. GeoBlock schlägt das Land jeder Besucher-IP nach und lässt nur die erlaubten durch. Diese Labels kommen an den Traefik-Container:</p>



<pre class="wp-block-code"><code>    labels:
      # --- GeoBlock: nur die aufgeführten Länder erlauben (+ private/Heim-IPs) ---
      - "traefik.http.middlewares.geoblock.plugin.geoblock.countries[0]=DE"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.countries[1]=AT"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowLocalRequests=true"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowUnknownCountries=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.unknownCountryApiResponse=nil"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.blackListMode=false"

      # Ausnahmen: ein Dienst, der dich aus dem Ausland erreichen muss
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowedIPAddresses[0]=203.0.113.0/24"

      # Länderabfrage
      - "traefik.http.middlewares.geoblock.plugin.geoblock.api=https://get.geojs.io/v1/ip/country/{ip}"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.apiTimeoutMs=750"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.cacheSize=100"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.forceMonthlyUpdate=true"

      # Logging: nur abgelehnte Anfragen, in eine eigene Datei
      - "traefik.http.middlewares.geoblock.plugin.geoblock.silentStartUp=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logLocalRequests=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logAllowedRequests=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logApiRequests=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logFilePath=/var/log/traefik/geoblock.log"</code></pre>



<ul class="wp-block-list">
<li><strong><code>countries</code>:</strong> die erlaubten Länder als Zwei-Buchstaben-Codes. Meine sind andere; wähl deine eigenen. Mit <code>blackListMode=false</code> ist das eine Erlaubnisliste: alles, was nicht drinsteht, wird gesperrt. Andersherum (wenige Länder sperren, den Rest erlauben) schützt viel weniger.</li>



<li><strong><code>allowLocalRequests=true</code>:</strong> private Adressen (dein Heimnetz) sind immer erlaubt. Ohne diese Zeile sperrst du dich zu Hause aus, sobald eine Anfrage mit lokaler Adresse ankommt.</li>



<li><strong><code>allowUnknownCountries=false</code>:</strong> findet die Abfrage kein Land, wird die Anfrage gesperrt. Sicherer, um den Preis einer seltenen Fehlsperre.</li>



<li><strong><code>allowedIPAddresses</code>:</strong> Ausnahmen für Dienste, die dich aus einem anderen Land aufrufen. Bei mir ist das der Connector eines KI-Assistenten zu Home Assistant, der aus dem veröffentlichten IP-Bereich des Anbieters in den USA kommt. Erlaube nur Bereiche, die der Anbieter offiziell veröffentlicht, nie einen geratenen.</li>



<li><strong>Abfrage und Cache:</strong> unbekannte IP-Adressen werden bei einem Online-Dienst nachgeschlagen (hier <code>geojs.io</code>), höchstens 750 ms lang. Die letzten 100 Ergebnisse bleiben im Cache, damit wiederkehrende Besucher keine neuen Abfragen auslösen.</li>



<li><strong>Log-Datei:</strong> jede gesperrte Anfrage landet in <code>geoblock.log</code>. Es ist erstaunlich lehrreich, sie nach einem Tag zu lesen: das meiste ist automatisiertes Scannen.</li>
</ul>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Bevor du es einschaltest:</strong> Auf Reisen in ein anderes Land sperrst du dich selbst aus, außer du verbindest dich vorher per VPN mit deinem Heimnetz. Und die Länderabfrage schickt die IP-Adressen der Besucher an einen externen Dienst. Für ein privates Homelab, das nur die Familie nutzt, ist das für mich in Ordnung; bei einer öffentlichen Website mit Besuchern gehört es in die Datenschutzerklärung, oder du nimmst stattdessen eine Offline-Länderdatenbank.</p>
</div>



<p class="wp-block-paragraph">Let&#8217;s Encrypt funktioniert auch mit GeoBlock. Die TLS-Challenge aus Teil 1 beantwortet Traefik, bevor irgendeine Middleware läuft, deshalb werden die Prüfserver im Ausland nicht gesperrt.</p>



<h2 class="wp-block-heading">3. CrowdSec: bekannte Angreifer und Angriffsmuster</h2>



<p class="wp-block-paragraph">CrowdSec macht zwei Dinge. Es liest das Access-Log von Traefik und merkt, wenn sich eine IP-Adresse wie ein Angreifer verhält (viele fehlgeschlagene Logins, Suche nach <code>/wp-login.php</code> auf einem Server ohne WordPress und so weiter). Und es teilt das mit einer Community-Liste, sodass du auch Adressen sperrst, die gestern jemand anderen angegriffen haben. Dazu arbeitet seine Komponente AppSec als Web Application Firewall (WAF): Sie prüft jede Anfrage gegen Regeln für typische Angriffe, bevor Traefik sie weiterleitet.</p>



<p class="wp-block-paragraph">Es gibt zwei Teile: die CrowdSec-Engine (ein eigener Container) und einen <em>Bouncer</em> in Traefik, der die Engine zu jeder Anfrage befragt.</p>



<h3 class="wp-block-heading">Die Engine</h3>



<pre class="wp-block-code"><code>  crowdsec:
    image: crowdsecurity/crowdsec:v1.8.1
    container_name: crowdsec
    restart: unless-stopped
    environment:
      # Erkennungsregeln: Traefik-Log-Parser, typische Web-Angriffe, bekannte CVEs, AppSec (WAF)
      COLLECTIONS: "crowdsecurity/traefik crowdsecurity/base-http-scenarios crowdsecurity/http-cve crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
      # Registriert den Traefik-Bouncer mit dem Schlüssel aus .env
      BOUNCER_KEY_traefik: ${CROWDSEC_BOUNCER_KEY}
      TZ: Europe/Berlin
    volumes:
      - ./crowdsec/config:/etc/crowdsec
      - ./crowdsec/data:/var/lib/crowdsec/data
      - ./crowdsec/acquis.d:/etc/crowdsec/acquis.d:ro
      - ./traefik-logs:/var/log/traefik:ro
    networks:
      - proxy
    # Keine veröffentlichten Ports: Traefik erreicht es als crowdsec:8080 und crowdsec:7422</code></pre>



<ul class="wp-block-list">
<li><strong>Collections</strong> sind Regelpakete aus dem CrowdSec-Hub: ein Parser für das Log-Format von Traefik, Szenarien für typische Web-Angriffe und bekannte Sicherheitslücken (CVEs) sowie die AppSec-Regelsätze.</li>



<li><strong><code>BOUNCER_KEY_traefik</code></strong> registriert Traefik als Bouncer mit einem Schlüssel, den du selbst wählst. Erzeug einen und schreib ihn in die <code>.env</code>-Datei neben der Compose-Datei, nie in die Compose-Datei selbst: <code>echo "CROWDSEC_BOUNCER_KEY=$(openssl rand -hex 32)" &gt;&gt; .env</code>.</li>



<li><strong>Der Log-Ordner von Traefik</strong> ist nur lesend eingebunden, damit CrowdSec das Access-Log aus Teil 1 lesen kann.</li>



<li><strong>Keine veröffentlichten Ports:</strong> nur Traefik spricht mit CrowdSec, über das gemeinsame Netzwerk <code>proxy</code>.</li>
</ul>



<p class="wp-block-paragraph">Was CrowdSec liest, steht in <code>crowdsec/acquis.d/traefik.yaml</code>:</p>



<pre class="wp-block-code"><code># 1. Traefik-Access-Log: Brute Force, Scans, bekannte Exploits
filenames:
  - /var/log/traefik/access.log
labels:
  type: traefik
---
# 2. AppSec (WAF): Traefik schickt jede Anfrage hierher, bevor es sie weitergibt
source: appsec
name: appsec
listen_addr: 0.0.0.0:7422
appsec_config: crowdsecurity/appsec-default
labels:
  type: appsec</code></pre>



<h3 class="wp-block-heading">Der Bouncer in Traefik</h3>



<pre class="wp-block-code"><code>    labels:
      # --- CrowdSec-Bouncer ---
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.enabled=true"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecMode=stream"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecLapiScheme=http"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecLapiHost=crowdsec:8080"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecLapiKey=${CROWDSEC_BOUNCER_KEY}"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.updateIntervalSeconds=60"
      # AppSec (WAF)
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecEnabled=true"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecHost=crowdsec:7422"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecFailureBlock=true"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecUnreachableBlock=false"
      # das Heimnetz nie sperren
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.clientTrustedIPs[0]=192.168.10.0/24"</code></pre>



<ul class="wp-block-list">
<li><strong><code>crowdsecMode=stream</code>:</strong> der Bouncer lädt alle 60 Sekunden die komplette Liste der gesperrten Adressen und prüft Anfragen lokal. Das ist schnell, und Traefik muss CrowdSec nicht bei jeder Anfrage fragen.</li>



<li><strong><code>crowdsecAppsecFailureBlock=true</code>:</strong> kann AppSec über eine Anfrage nicht entscheiden (ein Fehler bei der Prüfung), wird sie gesperrt.</li>



<li><strong><code>crowdsecAppsecUnreachableBlock=false</code>:</strong> ist der CrowdSec-Container nicht erreichbar, werden Anfragen durchgelassen. Das ist eine bewusste Abwägung: Während eines CrowdSec-Updates bleiben meine Dienste online, und GeoBlock filtert weiter. Wenn du lieber offline als ungeschützt bist, setz es auf <code>true</code>.</li>



<li><strong><code>clientTrustedIPs</code>:</strong> dein Heimnetz wird nie gesperrt. Ohne diese Zeile kann ein Familienmitglied, das fünfmal das falsche Passwort eintippt, das ganze Haus aussperren.</li>
</ul>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Docker Compose füllt <code>${CROWDSEC_BOUNCER_KEY}</code> auch in Labels aus der <code>.env</code>-Datei, deshalb steht der Schlüssel in keiner Datei, die du teilen oder öffentlich sichern würdest. Halte <code>.env</code> aus jedem Git-Repository heraus.</p>
</div>



<h3 class="wp-block-heading">Befehle für den Alltag</h3>



<pre class="wp-block-code"><code># welche Adressen sind gerade gesperrt, und warum?
docker exec crowdsec cscli decisions list

# was wurde zuletzt erkannt?
docker exec crowdsec cscli alerts list

# wird das Log gelesen, sprechen Bouncer und AppSec mit CrowdSec?
docker exec crowdsec cscli metrics

# eine Adresse entsperren (z. B. ein Freund, der gesperrt wurde)
docker exec crowdsec cscli decisions delete --ip 198.51.100.7

# eine Adresse von Hand für 24 Stunden sperren
docker exec crowdsec cscli decisions add --ip 198.51.100.7 --duration 24h</code></pre>



<p class="wp-block-paragraph">Das Access-Log und das GeoBlock-Log wachsen jeden Tag, deshalb rotiere ich sie mit logrotate auf dem Host (7 Tage, komprimiert). <code>copytruncate</code> lässt die Datei an ihrem Platz, sodass weder Traefik noch CrowdSec neu starten müssen:</p>



<pre class="wp-block-code"><code># /etc/logrotate.d/traefik
/pfad/zu/traefik/traefik-logs/*.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    copytruncate
}</code></pre>



<h2 class="wp-block-heading">4. Single Sign-on vor Apps</h2>



<p class="wp-block-paragraph">Manche Apps haben keinen eigenen Login, oder nur einen schwachen: das Dashboard von Traefik, das Zigbee2MQTT-Frontend, kleine Admin-Werkzeuge. Für sie fragt Traefik vor jeder Anfrage meinen Identity Provider Authentik: Ist diese Person angemeldet und darf sie diese App nutzen? Wenn nicht, sieht sie die Login-Seite von Authentik, mit Zwei-Faktor-Anmeldung. Das nennt sich <em>Forward Auth</em>.</p>



<p class="wp-block-paragraph">Die Middleware wird einmal definiert, mit Labels am Authentik-Server-Container:</p>



<pre class="wp-block-code"><code>    labels:
      # Pfad des eingebauten Outposts (nötig für Forward Auth auf anderen Domains)
      - traefik.http.routers.authentik-outpost.rule=HostRegexp(`.+`) &amp;&amp; PathPrefix(`/outpost.goauthentik.io/`)
      - traefik.http.routers.authentik-outpost.priority=1000
      - traefik.http.routers.authentik-outpost.entrypoints=websecure
      - traefik.http.routers.authentik-outpost.tls=true
      - traefik.http.routers.authentik-outpost.service=authentik

      # Forward-Auth-Middleware für Apps OHNE eigenen Login
      - traefik.http.middlewares.authentik.forwardauth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/traefik
      - traefik.http.middlewares.authentik.forwardauth.trustForwardHeader=true
      - traefik.http.middlewares.authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid</code></pre>



<ul class="wp-block-list">
<li><strong>Der Outpost-Router</strong> fängt den Pfad <code>/outpost.goauthentik.io/</code> auf <em>jedem</em> Hostnamen ab und schickt ihn an Authentik. Dorthin kommt die Login-Umleitung zurück. <code>priority=1000</code> sorgt dafür, dass er gegen den Router der App gewinnt.</li>



<li><strong><code>forwardauth.address</code>:</strong> diese Adresse fragt Traefik zu jeder Anfrage. Authentik antwortet mit „ja“ (die Anfrage geht weiter) oder mit einer Umleitung auf die Login-Seite.</li>



<li><strong><code>authResponseHeaders</code>:</strong> nach erfolgreichem Login gibt Authentik Benutzername, Gruppen und E-Mail als Header an die App weiter. Apps, die sie verstehen, können den Benutzer automatisch anmelden.</li>
</ul>



<p class="wp-block-paragraph">Eine App zu schützen ist dann ein zusätzliches Label an ihrem Router, hier für das Traefik-Dashboard aus Teil 1:</p>



<pre class="wp-block-code"><code>      - "traefik.http.routers.traefik.middlewares=authentik@docker"</code></pre>



<p class="wp-block-paragraph">In Authentik braucht jede geschützte App einen <em>Proxy Provider</em> im Forward-Auth-Modus (einzelne Anwendung) und eine Anwendung, die an die erlaubten Benutzer oder Gruppen gebunden ist. Ohne das kennt Authentik den Hostnamen nicht und lehnt alle ab.</p>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Nicht für jede App:</strong> Forward Auth funktioniert nur im Browser. Mobile Apps und andere Clients, die mit einer API sprechen (die Home-Assistant-App, Passwortmanager-Apps, Sync-Clients), können die Login-Seite von Authentik nicht anzeigen und funktionieren einfach nicht mehr. Für diese Apps nutze ich ihr eigenes Single Sign-on über OpenID Connect, wo sie es unterstützen (für WordPress habe ich das in <a href="/de/wordpress-sso-mit-authentik/">diesem Artikel</a> erklärt), und sonst ihren eigenen Login plus die Schichten oben.</p>
</div>



<h2 class="wp-block-heading">5. Security-Header für jeden Dienst</h2>



<p class="wp-block-paragraph">Security-Header sind kurze Anweisungen, die der Server mit jeder Seite an den Browser schickt. Viele Apps setzen einige davon, aber selten alle, und jede App macht es anders. Eine dritte Middleware am Entrypoint ergänzt die wichtigsten bei jedem Dienst und entfernt die Header, die Angreifern nur verraten, welche Software und Version du einsetzt. Diese Labels kommen an den Traefik-Container, und die Entrypoint-Zeile aus Schritt 1 bekommt einen dritten Eintrag: <code>geoblock@docker,crowdsec@docker,secheaders@docker</code>.</p>



<pre class="wp-block-code"><code>    labels:
      # --- Security-Header für jede Seite auf 443 ---
      # HSTS: Browser nutzen nur noch https, für 1 Jahr
      - "traefik.http.middlewares.secheaders.headers.stsSeconds=31536000"
      - "traefik.http.middlewares.secheaders.headers.contentTypeNosniff=true"
      # Softwarenamen und Versionen verbergen (ein leerer Wert entfernt den Header)
      - "traefik.http.middlewares.secheaders.headers.customResponseHeaders.Server="
      - "traefik.http.middlewares.secheaders.headers.customResponseHeaders.X-Powered-By="</code></pre>



<ul class="wp-block-list">
<li><strong>HSTS (<code>stsSeconds</code>):</strong> nach dem ersten Besuch weigert sich der Browser ein Jahr lang, die Seite über reines HTTP zu öffnen, auch wenn ihn jemand in einem öffentlichen WLAN dorthin umleiten will. <code>includeSubdomains</code> und <code>preload</code> habe ich bewusst weggelassen: Sie binden auch jede andere Subdomain deiner Domain an HTTPS, und preload lässt sich nur sehr schwer rückgängig machen.</li>



<li><strong><code>contentTypeNosniff</code>:</strong> der Browser vertraut dem angegebenen Dateityp und rät nicht. Das verhindert, dass ein hochgeladenes „Bild“ als Skript ausgeführt wird.</li>



<li><strong><code>Server</code> und <code>X-Powered-By</code> entfernen:</strong> vorher haben meine Dienste jedem Dinge wie <code>Server: nginx</code> oder <code>X-Powered-By: PHP/8.5</code> erzählt. Das macht nichts angreifbar, erspart einem Angreifer aber den ersten Schritt, Exploits für genau diese Version zu suchen.</li>
</ul>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Nicht jeder Header gehört hierher:</strong> <code>X-Frame-Options</code> und eine Content Security Policy hängen von der App ab. Meine Office-Suite wird in Nextcloud in einem Frame angezeigt; ein zentraler „nie in einem Frame anzeigen“-Header würde sie kaputt machen. Diese überlasse ich den Apps, die sie meist sowieso richtig setzen. Öffne nach dem Hinzufügen der Middleware jeden Dienst einmal und prüf, ob er noch funktioniert.</p>
</div>



<p class="wp-block-paragraph">Prüfen geht mit einem Befehl pro Dienst. Du solltest <code>strict-transport-security</code> und <code>x-content-type-options</code> sehen und keine <code>server</code>-Zeile:</p>



<pre class="wp-block-code"><code>curl -s -o /dev/null -D - https://app.example.com/ | grep -i -E "strict-transport|x-content-type|^server|x-powered"</code></pre>



<h2 class="wp-block-heading">6. Grundregeln für die Container</h2>



<p class="wp-block-paragraph">Ein paar kleine Einstellungen an den Containern selbst, die nichts kosten:</p>



<pre class="wp-block-code"><code>  traefik:
    image: traefik:v3.7
    security_opt:
      - no-new-privileges:true
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      # ...</code></pre>



<ul class="wp-block-list">
<li><strong><code>no-new-privileges</code>:</strong> Prozesse im Container können sich später keine zusätzlichen Rechte verschaffen (zum Beispiel über ein setuid-Programm). Bricht je jemand in Traefik ein, macht das den nächsten Schritt schwerer.</li>



<li><strong>Docker-Socket nur lesend:</strong> Traefik muss Container-Informationen nur lesen. Aber Achtung: <code>:ro</code> schützt nur die Socket-<em>Datei</em>; die Docker-API dahinter nimmt trotzdem Befehle an. Strenger ist ein Socket-Proxy, ein kleiner Container, der nur die Lese-Anfragen durchlässt, die Traefik braucht. Für mein Monitoring-Portal nutze ich so einen schon, und Traefik soll auch noch umziehen.</li>



<li><strong>Keine veröffentlichten Ports</strong> bei keinem Dienst außer Traefik (80 und 443). Alles andere ist nur über Traefik erreichbar und damit über alle Schichten oben. Eine vergessene <code>ports:</code>-Zeile umgeht sie alle.</li>



<li><strong><code>exposedbydefault=false</code></strong> aus Teil 1 gehört auch hierher: nichts wird aus Versehen öffentlich.</li>
</ul>



<h2 class="wp-block-heading">7. Testen, ob es funktioniert</h2>



<p class="wp-block-paragraph">Trau keinem Sicherheits-Setup, das du nicht getestet hast. Diese Prüfungen dauern fünf Minuten. Mach die Angriffstests von <em>außerhalb</em> deines Heimnetzes (mobile Daten, nicht WLAN), weil dein Heimnetz auf jeder Erlaubnisliste steht:</p>



<pre class="wp-block-code"><code># AppSec sollte typische Angriffs-Anfragen mit 403 sperren
curl -s -o /dev/null -w "%{http_code}n" "https://app.example.com/?id=1%27%20OR%20%271%27=%271"
curl -s -o /dev/null -w "%{http_code}n" "https://app.example.com/../../etc/passwd"
curl -s -o /dev/null -w "%{http_code}n" "https://app.example.com/.env"

# hat CrowdSec es gesehen?
docker exec crowdsec cscli alerts list | head

# GeoBlock: Anfragen aus anderen Ländern stehen hier
tail -n 5 traefik-logs/geoblock.log

# Forward Auth: ohne Login solltest du zu Authentik umgeleitet werden (302)
curl -s -o /dev/null -w "%{http_code} %{redirect_url}n" https://traefik.example.com/</code></pre>



<p class="wp-block-paragraph">Um GeoBlock aus einem anderen Land zu testen, nimm ein VPN mit Ausgang in einem gesperrten Land oder einen der vielen Online-Dienste, die eine Website „von einem anderen Standort“ prüfen. Du solltest ein <code>403</code> bekommen.</p>



<h2 class="wp-block-heading">Was ich (noch) nicht mache</h2>



<ul class="wp-block-list">
<li><strong>Zentrale Frame- und Content-Richtlinien</strong> (<code>X-Frame-Options</code>, Content Security Policy): sie hängen zu sehr von der jeweiligen App ab, deshalb setzen die Apps sie selbst (siehe Schritt 5).</li>



<li><strong>Eigene TLS-Optionen:</strong> brauche ich nicht. Ich habe mit <code>openssl s_client</code> geprüft: Die Standardwerte von Traefik lehnen TLS 1.0 und 1.1 schon ab und akzeptieren nur 1.2 und 1.3.</li>



<li><strong>Rate Limiting:</strong> die <code>ratelimit</code>-Middleware von Traefik auf Login-Seiten. CrowdSec deckt das meiste ab, weil es Brute-Force-Versuche sperrt.</li>



<li><strong>Der Socket-Proxy für Traefik,</strong> wie oben erwähnt.</li>
</ul>



<h2 class="wp-block-heading">Was ich gelernt habe</h2>



<ul class="wp-block-list">
<li><strong>Middlewares am Entrypoint schlagen Middlewares pro Router:</strong> neue Dienste sind ab der ersten Sekunde geschützt, ohne dass ich an etwas denken muss.</li>



<li><strong>Erlaubnislisten schlagen Sperrlisten.</strong> Die zwei Länder aufzuzählen, die ich brauche, ist sicherer und einfacher als die hundert, die ich nicht brauche.</li>



<li><strong>Das Heimnetz immer ausnehmen</strong>, in GeoBlock und in CrowdSec. Sonst bist du selbst der Erste, der ausgesperrt wird.</li>



<li><strong>Entscheide, was passiert, wenn eine Schutzschicht ausfällt:</strong> alles sperren oder durchlassen. Beides ist vertretbar, aber es sollte eine Entscheidung sein, kein Standardwert, den du nie angesehen hast.</li>



<li><strong>Teste von außen.</strong> Vom eigenen Sofa aus sieht alles sicher aus, weil dein Heimnetz auf jeder Erlaubnisliste steht.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Traefik als Reverse Proxy für Docker: meine Konfiguration erklärt</title>
		<link>https://selfhostjournal.eu/de/traefik-reverse-proxy-docker-konfiguration/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 11:57:09 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=128</guid>

					<description><![CDATA[Ein Traefik-Container vor allen selbst gehosteten Diensten: Entrypoints, Labels, Routen für VMs, Let's-Encrypt-Zertifikate und das Dashboard, Zeile für Zeile erklärt.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">Kurz gesagt</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Jeder selbst gehostete Dienst will seinen eigenen Port, sein eigenes Zertifikat und seinen eigenen Weg nach draußen. Spätestens ab dem dritten Dienst wird das unübersichtlich.</p>



<p class="wp-block-paragraph"><strong>Lösung:</strong> Ein einziger Traefik-Container vor allem anderen. Er nimmt Anfragen auf Port 80 und 443 an, holt sich Let&#8217;s-Encrypt-Zertifikate selbst und schickt jede Anfrage an den richtigen Dienst, gesteuert über ein paar Labels in der jeweiligen <code>docker-compose.yml</code>.</p>



<p class="wp-block-paragraph"><strong>Getestet mit:</strong> Traefik 3.7, Docker Compose auf einem kleinen Heimserver, Let&#8217;s Encrypt, etwa zehn Dienste (Docker-Container und eine Home-Assistant-VM)</p>
</div>



<p class="wp-block-paragraph">Traefik ist die Haustür meines Homelabs. Jede Anfrage aus dem Internet kommt zuerst dort an, und Traefik entscheidet, welcher Dienst sie bekommt. In diesem Artikel erkläre ich meine Konfiguration Zeile für Zeile: wie Traefik eingerichtet ist, wie ein Container über Labels veröffentlicht wird, wie Dienste außerhalb von Docker dazukommen und wie die Zertifikate funktionieren. Die Absicherung (Geo-Blocking, CrowdSec, Single Sign-on vor den Diensten) lasse ich bewusst weg. Sie bekommt <a href="/de/traefik-absichern-geoblock-crowdsec-sso/">einen eigenen Artikel</a>, weil sie auf allem hier aufbaut.</p>



<h2 class="wp-block-heading">1. Wie Traefik denkt</h2>



<p class="wp-block-paragraph">Traefik hat vier Bausteine. Wenn die klar sind, ergibt jede Zeile der Konfiguration Sinn:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Baustein</th><th>Was er tut</th><th>In meinem Setup</th></tr></thead><tbody><tr><td>Entrypoint</td><td>Ein Port, auf dem Traefik lauscht</td><td><code>web</code> (80) und <code>websecure</code> (443)</td></tr><tr><td>Router</td><td>Eine Regel, die Anfragen erkennt, meist am Hostnamen</td><td>einer pro Dienst, z. B. <code>Host(`vault.example.com`)</code></td></tr><tr><td>Middleware</td><td>Etwas, das eine Anfrage unterwegs verändert oder prüft</td><td>die Umleitung von HTTP auf HTTPS (mehr im Artikel zur Absicherung)</td></tr><tr><td>Service</td><td>Wohin die Anfrage am Ende geht</td><td>ein Container-Port oder eine Adresse im lokalen Netz</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Außerdem gibt es zwei Arten von Konfiguration. Die <strong>statische Konfiguration</strong> wird einmal beim Start gelesen: Entrypoints, Zertifikatseinstellungen, wo die Routen herkommen. Die <strong>dynamische Konfiguration</strong> sind die Routen selbst. Traefik liest sie aus Docker-Labels und aus Dateien und übernimmt Änderungen ohne Neustart.</p>



<h2 class="wp-block-heading">2. Bevor du anfängst</h2>



<ol class="wp-block-list">
<li><strong>DNS:</strong> ein A-Record für jeden Hostnamen (zum Beispiel <code>vault.example.com</code>), der auf deine öffentliche IP-Adresse zeigt. Ein Wildcard-Eintrag geht auch.</li>



<li><strong>Router:</strong> leite die Ports 80 und 443 von deinem Internet-Router auf den Docker-Host weiter. Mehr muss nicht offen sein.</li>



<li><strong>Ein gemeinsames Docker-Netzwerk</strong>, in dem Traefik und alle veröffentlichten Container hängen:</li>
</ol>



<pre class="wp-block-code"><code>docker network create proxy</code></pre>



<ol start="4" class="wp-block-list">
<li><strong>Eine Datei für die Zertifikate.</strong> Traefik verweigert sie, wenn andere Benutzer sie lesen können:</li>
</ol>



<pre class="wp-block-code"><code>mkdir -p traefik-data/dynamic
touch traefik-data/acme.json
chmod 600 traefik-data/acme.json</code></pre>



<h2 class="wp-block-heading">3. Der Traefik-Container</h2>



<p class="wp-block-paragraph">Die ganze statische Konfiguration steht bei mir im <code>command</code>-Abschnitt der Compose-Datei. Eine eigene <code>traefik.yml</code> funktioniert genauso gut; mir gefällt, alles an einer Stelle zu haben.</p>



<pre class="wp-block-code"><code>services:
  traefik:
    image: traefik:v3.7
    container_name: traefik
    restart: unless-stopped
    networks:
      - proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik-data/acme.json:/acme.json
      - ./traefik-data/dynamic:/dynamic:ro
      - ./traefik-logs:/var/log/traefik
    command:
      # Allgemein
      - "--global.checknewversion=false"
      - "--global.sendanonymoususage=false"
      - "--log.level=INFO"
      - "--accesslog=true"
      - "--accesslog.filepath=/var/log/traefik/access.log"
      - "--api.dashboard=true"

      # Woher die Routen kommen
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--providers.docker.network=proxy"
      - "--providers.file.directory=/dynamic"
      - "--providers.file.watch=true"

      # Ports
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"

      # Alles von http auf https umleiten
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"

      # Let's Encrypt
      - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email=you@example.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"

      # Kein Timeout für lange Uploads (Nextcloud)
      - "--entrypoints.web.transport.respondingTimeouts.readTimeout=0"
      - "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0"

networks:
  proxy:
    external: true</code></pre>



<p class="wp-block-paragraph">Was die Zeilen tun:</p>



<ul class="wp-block-list">
<li><strong>Docker-Socket:</strong> Traefik beobachtet Docker, um zu sehen, welche Container starten und stoppen und welche Labels sie haben. Nur-Lesen reicht dafür. Wie man das weiter absichert, kommt im Artikel zur Absicherung.</li>



<li><strong><code>exposedbydefault=false</code>:</strong> nur Container mit dem Label <code>traefik.enable=true</code> werden veröffentlicht. Ohne diese Zeile wäre jeder Container, den du startest, aus dem Internet erreichbar.</li>



<li><strong><code>providers.docker.network=proxy</code>:</strong> Traefik spricht die Container über das gemeinsame Netzwerk <code>proxy</code> an. Das ist wichtig, wenn ein Container in mehreren Netzwerken hängt: sonst nimmt Traefik vielleicht eine Adresse, die es nicht erreicht.</li>



<li><strong>File-Provider:</strong> jede <code>.yml</code>-Datei in <code>/dynamic</code> wird als zusätzliche Route gelesen, und <code>watch=true</code> übernimmt Änderungen beim Speichern. So kommen Dienste außerhalb von Docker dazu (Schritt 5).</li>



<li><strong>Umleitung:</strong> eine Anfrage an <code>http://</code> wird mit einer Umleitung auf <code>https://</code> beantwortet. Kein Dienst braucht eine eigene Umleitung.</li>



<li><strong>Access-Log:</strong> eine Zeile pro Anfrage. Die erste Anlaufstelle, wenn etwas nicht ankommt, und später die Grundlage für die Angriffserkennung.</li>



<li><strong><code>readTimeout=0</code>:</strong> der Standard-Timeout von Traefik hat große Nextcloud-Uploads abgebrochen. Null heißt: kein Limit. Wenn du keine großen Dateien hochlädst, lass die Zeilen weg.</li>
</ul>



<h3 class="wp-block-heading">Welches Versions-Tag du nehmen solltest</h3>



<p class="wp-block-paragraph">Der Teil nach dem Doppelpunkt in <code>image: traefik:v3.7</code> ist das <strong>Tag</strong>. Es bestimmt, welche Traefik-Version Docker herunterlädt, und welche Version du nach dem nächsten <code>docker compose pull</code> bekommst. Traefik veröffentlicht dieselbe Version unter mehreren Tags:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Tag</th><th>Was du beim nächsten Pull bekommst</th><th>Risiko</th></tr></thead><tbody><tr><td><code>latest</code></td><td>Immer die neueste Version, auch die nächste Hauptversion (irgendwann v4)</td><td>Am höchsten: eine Hauptversion kann Optionen entfernen oder umbenennen, und deine Routen funktionieren nicht mehr</td></tr><tr><td><code>v3</code></td><td>Jede neue 3.x-Version (3.7, 3.8, …)</td><td>Gering: Nebenversionen bringen neue Funktionen und ändern nur selten das Verhalten</td></tr><tr><td><code>v3.7</code></td><td>Nur Fehler- und Sicherheitskorrekturen von 3.7 (3.7.16, 3.7.17, …)</td><td>Am geringsten, aber auf 3.8 musst du das Tag selbst ändern</td></tr><tr><td><code>v3.7.16</code></td><td>Genau diese Version, für immer</td><td>Keins durch Updates, aber auch keine Sicherheitskorrekturen, bis du es änderst</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Traefik nutzt semantische Versionierung: die erste Zahl (Hauptversion) darf deine Konfiguration brechen, die zweite (Nebenversion) bringt neue Funktionen, die dritte (Patch) behebt nur Fehler und Sicherheitslücken. Je sicherer das Tag, desto öfter musst du es von Hand anpassen.</p>



<p class="wp-block-paragraph">Warum das gerade beim Reverse Proxy zählt: Wenn Traefik ausfällt, sind <em>alle</em> Dienste auf einmal offline, nicht nur einer. Und in vielen Homelabs laufen die Updates nachts automatisch. Mit <code>latest</code> kann eine neue Hauptversion um 4 Uhr alles lahmlegen, und du merkst es morgens, wenn nichts mehr lädt.</p>



<p class="wp-block-paragraph">Meine Empfehlung: <code>v3</code>, wenn deine Updates automatisch laufen, oder <code>v3.7</code>, wenn du von Hand aktualisierst und die Release Notes sowieso liest. Traefik hängt direkt im Internet, also pinne keine exakte Version und vergiss sie dann. Sicherheitskorrekturen kommen nur mit neuen Patch-Versionen.</p>



<p class="wp-block-paragraph">Aktualisieren und Zurückgehen ist jeweils eine Änderung in der Compose-Datei:</p>



<pre class="wp-block-code"><code># welche Version läuft gerade?
docker exec traefik traefik version

# Update: Tag bei Bedarf ändern (z. B. v3.7 -&gt; v3.8), dann
docker compose pull traefik
docker compose up -d traefik
docker logs traefik --since 2m    # nach Fehlern schauen

# etwas kaputt? altes Tag wieder eintragen und dieselben zwei Befehle erneut ausführen</code></pre>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> lies vor einem Neben- oder Hauptversions-Update die Migrationshinweise in der Traefik-Dokumentation (Abschnitt „Migration“). Dort steht genau, welche Optionen sich geändert haben. Bei Patch-Updates ist das nicht nötig.</p>
</div>



<p class="wp-block-paragraph">Starte den Container mit <code>docker compose up -d</code> und prüfe <code>docker logs traefik</code>. Dort sollten keine Fehler stehen, nur eine Zeile, dass die Provider gestartet sind.</p>



<h2 class="wp-block-heading">4. Einen Container mit Labels veröffentlichen</h2>



<p class="wp-block-paragraph">Hier spielt Traefik seine Stärke aus. Ein Dienst veröffentlicht keinen Port auf dem Host. Er hängt im Netzwerk <code>proxy</code> und beschreibt seine Route mit Labels. Als Beispiel mein Vaultwarden, gekürzt auf den Traefik-Teil:</p>



<pre class="wp-block-code"><code>services:
  vaultwarden:
    image: vaultwarden/server:latest
    restart: unless-stopped
    networks:
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.vaultwarden.rule=Host(`vault.example.com`)"
      - "traefik.http.routers.vaultwarden.entrypoints=websecure"
      - "traefik.http.routers.vaultwarden.tls.certresolver=letsencrypt"
      - "traefik.http.routers.vaultwarden.service=vaultwarden"
      - "traefik.http.services.vaultwarden.loadbalancer.server.port=80"

networks:
  proxy:
    external: true</code></pre>



<ul class="wp-block-list">
<li><strong>Router-Name</strong> (<code>vaultwarden</code>): frei wählbar, aber über alle Container eindeutig. Einen Block kopieren und vergessen, ihn umzubenennen, ist der häufigste Fehler; der zweite Container ersetzt dann stillschweigend den ersten.</li>



<li><strong><code>rule</code>:</strong> welcher Hostname zu diesem Dienst gehört. Regeln lassen sich kombinieren, zum Beispiel <code>Host(`example.com`) &amp;&amp; PathPrefix(`/api`)</code>.</li>



<li><strong><code>entrypoints=websecure</code>:</strong> nur HTTPS. Reines HTTP erreicht den Router nie, weil der Entrypoint vorher umleitet.</li>



<li><strong><code>tls.certresolver=letsencrypt</code>:</strong> Traefik holt das Zertifikat für den Hostnamen bei der ersten Anfrage und erneuert es selbst.</li>



<li><strong><code>server.port</code>:</strong> der Port <em>im</em> Container, nicht auf dem Host. Meist errät Traefik ihn aus dem Image, aber ausdrücklich angegeben vermeidet er Überraschungen bei Images mit mehreren Ports.</li>
</ul>



<p class="wp-block-paragraph">Nach <code>docker compose up -d</code> ist der Dienst unter seinem Namen online, mit gültigem Zertifikat. Kein Traefik-Neustart, keine Portweiterleitung, keine Zertifikatsverwaltung.</p>



<h2 class="wp-block-heading">5. Dienste außerhalb von Docker</h2>



<p class="wp-block-paragraph">Mein Home Assistant läuft in einer virtuellen Maschine, nicht in Docker, und hat deshalb keine Labels. Für solche Dienste schreibe ich die Route als Datei in den Ordner <code>dynamic</code>:</p>



<pre class="wp-block-code"><code># traefik-data/dynamic/homeassistant.yml
http:
  routers:
    homeassistant:
      rule: "Host(`home.example.com`)"
      entryPoints:
        - websecure
      service: homeassistant-service
      tls:
        certResolver: letsencrypt

  services:
    homeassistant-service:
      loadBalancer:
        servers:
          - url: "http://192.0.2.20:8123"</code></pre>



<p class="wp-block-paragraph">Die Adresse ist die lokale IP-Adresse der VM mit Port. Datei speichern, und die Route ist aktiv; im Log von Traefik steht, dass es die neue Konfiguration gelesen hat. Genauso funktioniert es mit einem NAS, der Weboberfläche eines Routers oder allem anderen in deinem Netz, das HTTP spricht.</p>



<p class="wp-block-paragraph">Home Assistant muss außerdem wissen, dass es hinter einem Proxy steht, sonst lehnt es die Anfragen mit „400 Bad Request“ ab. In seiner <code>configuration.yaml</code>:</p>



<pre class="wp-block-code"><code>http:
  use_x_forwarded_for: true
  trusted_proxies:
    - 192.0.2.10   # der Docker-Host, auf dem Traefik läuft</code></pre>



<h2 class="wp-block-heading">6. Zertifikate mit Let&#8217;s Encrypt</h2>



<p class="wp-block-paragraph">Ich nutze die <strong>TLS-Challenge</strong>: Let&#8217;s Encrypt verbindet sich auf Port 443, und Traefik beweist genau dort, dass es den Hostnamen kontrolliert. Die Vorteile: kein zusätzlicher Port, kein API-Schlüssel beim DNS-Anbieter, und es funktioniert für jeden Hostnamen, der auf deine IP-Adresse zeigt. Die Grenze: keine Wildcard-Zertifikate, dafür brauchst du die DNS-Challenge.</p>



<p class="wp-block-paragraph">Alle Zertifikate und der Kontoschlüssel landen in <code>acme.json</code>. Sichere diese Datei. Ohne sie fordert Traefik nach einem Neuaufbau jedes Zertifikat neu an, und Let&#8217;s Encrypt hat Ratenlimits. Wie ich das ganze Homelab sichere, erkläre ich in <a href="/de/homelab-backup-snapshots-dumps-offline-kopie/">einem eigenen Artikel</a>.</p>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Achtung, Ratenlimits:</strong> solange du noch ausprobierst, ergänze <code>--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory</code>. Die Staging-Zertifikate akzeptiert kein Browser, aber du kannst dich nicht durch zu viele Fehlversuche für eine Woche aussperren. Wenn alles läuft, Zeile entfernen und <code>acme.json</code> leeren.</p>
</div>



<h2 class="wp-block-heading">7. Das Dashboard</h2>



<p class="wp-block-paragraph">Das Dashboard von Traefik zeigt alle Router, Services und Middlewares, die es kennt, und ob sie Fehler haben. Es ist der schnellste Weg herauszufinden, warum eine Route nicht funktioniert. Ich veröffentliche es wie jeden anderen Dienst, mit Labels am Traefik-Container selbst:</p>



<pre class="wp-block-code"><code>    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.traefik.entrypoints=websecure"
      - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
      - "traefik.http.routers.traefik.service=api@internal"</code></pre>



<p class="wp-block-paragraph"><code>api@internal</code> ist der eingebaute Service von Traefik für das Dashboard; einen Port braucht es nicht.</p>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Achtung, Dashboard nicht offen lassen:</strong> so wie hier gezeigt, kann jeder dein ganzes Setup sehen. In meinem Homelab liegt es hinter Single Sign-on. Wie das geht, kommt im <a href="/de/traefik-absichern-geoblock-crowdsec-sso/">Artikel zur Absicherung</a>. Bis dahin veröffentliche das Dashboard entweder gar nicht oder setze mindestens ein Passwort davor (die Middleware <code>basicauth</code> von Traefik).</p>
</div>



<h2 class="wp-block-heading">8. Wenn etwas nicht funktioniert</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Was du siehst</th><th>Häufige Ursache</th></tr></thead><tbody><tr><td><code>404 page not found</code> von Traefik</td><td>Kein Router passt: Tippfehler im Hostnamen, <code>traefik.enable=true</code> fehlt, oder der Container läuft nicht</td></tr><tr><td><code>502 Bad Gateway</code></td><td>Router gefunden, aber Traefik erreicht den Dienst nicht: falscher <code>server.port</code>, oder der Container hängt nicht im Netzwerk <code>proxy</code></td></tr><tr><td><code>504 Gateway Timeout</code></td><td>Der Dienst ist erreichbar, aber zu langsam, oder eine Firewall verwirft die Verbindung (typisch bei VMs)</td></tr><tr><td>Zertifikat „TRAEFIK DEFAULT CERT“</td><td>Let&#8217;s Encrypt ist gescheitert: Port 443 von außen nicht erreichbar, DNS zeigt noch nicht auf dich, oder ein Ratenlimit. Der Grund steht in <code>docker logs traefik</code></td></tr><tr><td>Ein Dienst zeigt die Seite eines anderen</td><td>Zwei Container nutzen denselben Router-Namen</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Zwei Befehle decken die meisten Fälle ab:</p>



<pre class="wp-block-code"><code># Fehler beim Lesen der Routen oder beim Holen der Zertifikate
docker logs traefik 2&gt;&amp;1 | grep -i -E "error|acme"

# Ist die Anfrage bei Traefik angekommen, und was hat es geantwortet?
tail -f traefik-logs/access.log</code></pre>



<h2 class="wp-block-heading">Was ich gelernt habe</h2>



<ul class="wp-block-list">
<li><strong>Labels schlagen Konfigurationsdateien</strong> bei Containern: die Route steht direkt neben dem Dienst, zu dem sie gehört, und verschwindet mit ihm.</li>



<li><strong><code>exposedbydefault=false</code> ist nicht optional.</strong> Veröffentlichen sollte immer eine bewusste Entscheidung pro Container sein.</li>



<li><strong>Ein gemeinsames Netzwerk</strong> und <code>traefik.docker.network=proxy</code> an jedem Dienst haben mir die meisten 502-Fehler erspart.</li>



<li><strong>Sichere <code>acme.json</code></strong> zusammen mit den Compose-Dateien.</li>



<li><strong>Ein Reverse Proxy ist keine Firewall.</strong> Er entscheidet, <em>wohin</em> eine Anfrage geht, nicht <em>ob</em> sie herein darf. Darum geht es im <a href="/de/traefik-absichern-geoblock-crowdsec-sso/">nächsten Artikel</a>: Geo-Blocking, CrowdSec und Single Sign-on vor den Routen von Traefik.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Das papierlose Büro: ein praktischer Leitfaden mit Paperless-ngx, GoBD und Aufbewahrungsfristen</title>
		<link>https://selfhostjournal.eu/de/papierloses-buero-leitfaden/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Thu, 22 Oct 2026 20:26:49 +0000</pubDate>
				<category><![CDATA[Erfahrungen]]></category>
		<category><![CDATA[Homelab]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=103</guid>

					<description><![CDATA[So gelingt das papierlose Büro im kleinen deutschen Betrieb: Paperless-ngx, E-Rechnungen, tägliche Abläufe, GoBD-Verfahrensdokumentation und die aktuellen Aufbewahrungsfristen.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">Kurz gesagt</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Papier stapelt sich, Rechnungen kommen per E-Mail, als PDF und jetzt auch als XML, und das deutsche Recht erwartet, dass du alles bis zu zehn Jahre lang auffindbar und unverändert aufbewahrst.</p>



<p class="wp-block-paragraph"><strong>Lösung:</strong> Ein selbst gehostetes Dokumentenarchiv (Paperless-ngx), ein Netzwerkscanner, ein paar klare tägliche Abläufe, eine schriftliche Verfahrensdokumentation und ein Aufbewahrungsplan nach den aktuellen deutschen Regeln.</p>



<p class="wp-block-paragraph"><strong>Getestet mit:</strong> Paperless-ngx in Docker auf einem kleinen Heimserver, ein Netzwerkscanner mit Dokumenteneinzug, Nextcloud als Dateiablage, Rechtsstand Deutschland Oktober 2026</p>
</div>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Keine Steuer- oder Rechtsberatung:</strong> Diese Anleitung beschreibt, wie wir das für einen kleinen deutschen Betrieb eingerichtet haben und was die Regeln nach meinem Verständnis sagen. Aufbewahrungsfristen und Anforderungen ändern sich. Lass dein eigenes Setup von deiner Steuerberatung prüfen, bevor du ein Papieroriginal vernichtest.</p>
</div>



<p class="wp-block-paragraph">Kleine Betriebe und Homeoffices ertrinken in Papier: Briefe, Quittungen, Kontoauszüge, Verträge. Dazu kommen immer mehr Rechnungen per E-Mail, und seit 2025 muss jedes Unternehmen in Deutschland elektronische Rechnungen empfangen können. Ein guter Zeitpunkt, mit dem Abheften aufzuhören und ein richtiges papierloses Büro aufzubauen. Meins läuft in meinem Homelab, und die Beispiele unten stammen aus diesem Setup.</p>



<h2 class="wp-block-heading">1. Was „papierlos“ bedeuten sollte</h2>



<p class="wp-block-paragraph">Bevor du einen Scanner kaufst, leg das Ziel fest. Für uns waren es vier Regeln:</p>



<ol class="wp-block-list">
<li><strong>Ein Archiv</strong> für alles: Gescanntes Papier, E-Mail-Anhänge und E-Rechnungen landen am selben Ort.</li>



<li><strong>Digitale Originale bleiben Originale.</strong> Eine PDF- oder XML-Rechnung, die per E-Mail kam, wird genau so gespeichert, wie sie angekommen ist. Ausdrucken und wieder einscannen ist nicht erlaubt.</li>



<li><strong>Alles ist durchsuchbar</strong> dank Texterkennung (OCR) und ein paar wenigen Tags.</li>



<li><strong>Nichts geht verloren:</strong> automatische Backups, darunter eine Kopie außer Haus.</li>
</ol>



<h2 class="wp-block-heading">2. Das technische Setup</h2>



<p class="wp-block-paragraph">Die Bausteine sind günstig und größtenteils Open Source:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Baustein</th><th>Was wir nutzen</th><th>Warum</th></tr></thead><tbody><tr><td>Scanner</td><td>Netzwerk-Multifunktionsdrucker mit Dokumenteneinzug und Duplex</td><td>Scannt einen ganzen Stapel Briefe in einem Durchgang direkt ins Netz</td></tr><tr><td>Scan-Oberfläche</td><td>scanservjs (Weboberfläche für den Scanner)</td><td>Jeder im Haus kann im Browser scannen, ohne Treiber auf jedem PC</td></tr><tr><td>Archiv</td><td>Paperless-ngx in Docker</td><td>OCR, Tags, Volltextsuche, behält die Originaldatei, mehrere Benutzer</td></tr><tr><td>Datenbank</td><td>PostgreSQL</td><td>Zuverlässig, einfach per Dump zu sichern</td></tr><tr><td>Dateiablage</td><td>Nextcloud</td><td>Eine lesbare Ordnerstruktur für alle, die Paperless nicht direkt nutzen, und für die Steuerberatung</td></tr><tr><td>Anmeldung</td><td>Single Sign-on vor allem</td><td>Ein Login, keine geteilten Passwörter</td></tr><tr><td>Backup</td><td>Nächtliche Snapshots plus verschlüsselte Kopie außer Haus</td><td>Zehn Jahre Dokumente müssen eine kaputte Festplatte überleben</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Ein minimaler Paperless-ngx-Stack sieht so aus. Passwörter und Schlüssel gehören in eine <code>.env</code>-Datei daneben, nie in die Compose-Datei:</p>



<pre class="wp-block-code"><code>services:
  broker:
    image: docker.io/library/redis:7
    restart: unless-stopped
  db:
    image: docker.io/library/postgres:17
    restart: unless-stopped
    volumes:
      - ./pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: paperless
      POSTGRES_USER: paperless
      POSTGRES_PASSWORD: ${DB_PASSWORD}
  webserver:
    image: ghcr.io/paperless-ngx/paperless-ngx:latest
    restart: unless-stopped
    depends_on: [db, broker]
    ports:
      - "127.0.0.1:8010:8000"   # nur über Reverse Proxy / SSO
    volumes:
      - ./data:/usr/src/paperless/data
      - ./media:/usr/src/paperless/media
      - ./consume:/usr/src/paperless/consume
      - ./export:/usr/src/paperless/export
    environment:
      PAPERLESS_REDIS: redis://broker:6379
      PAPERLESS_DBHOST: db
      PAPERLESS_DBPASS: ${DB_PASSWORD}
      PAPERLESS_SECRET_KEY: ${SECRET_KEY}
      PAPERLESS_URL: https://paperless.example.com
      PAPERLESS_TIME_ZONE: Europe/Berlin
      PAPERLESS_OCR_LANGUAGE: deu+eng
      PAPERLESS_FILENAME_FORMAT: "{{ created_year }}/{{ correspondent }}/{{ created }} {{ title }}"</code></pre>



<p class="wp-block-paragraph">Ein paar Einstellungen sind wichtiger, als sie aussehen:</p>



<ul class="wp-block-list">
<li><strong>OCR-Sprache:</strong> <code>deu+eng</code> erkennt deutsche Umlaute und englische Wörter im selben Dokument.</li>



<li><strong>Original und Archivversion:</strong> Paperless behält die Datei genau so, wie sie ankam, und erzeugt daneben ein durchsuchbares PDF/A. Für die Ordnungsmäßigkeit zählt das Original.</li>



<li><strong>Consume-Ordner:</strong> Scanner, E-Mail-Import und Menschen legen Dateien in einen Ordner. Paperless holt sie dort ab.</li>



<li><strong>Nicht im Internet:</strong> Der Port ist an den Server selbst gebunden und nur über einen Reverse Proxy mit Single Sign-on erreichbar. Ein Archiv voller Rechnungen, Verträge und Kontoauszüge ist eine Beute für Angreifer.</li>
</ul>



<h3 class="wp-block-heading">E-Rechnungen brauchen besondere Sorgfalt</h3>



<p class="wp-block-paragraph">Seit dem 1. Januar 2025 muss jedes Unternehmen in Deutschland elektronische Rechnungen von anderen inländischen Unternehmen <strong>empfangen</strong> können. Eine E-Rechnung ist eine strukturierte Datei: entweder eine XRechnung (reines XML) oder eine ZUGFeRD-Rechnung (ein PDF mit eingebettetem XML). Daraus folgen zwei Dinge:</p>



<ul class="wp-block-list">
<li>Das <strong>XML ist das rechtliche Original</strong>, nicht ein Ausdruck oder ein Screenshot. Speichere die Datei genau so, wie sie angekommen ist.</li>



<li>Eine reine XML-Datei ist für Menschen nicht lesbar. Halte einen Viewer bereit (zum Beispiel einen kostenlosen XRechnung-Viewer) und speichere eine lesbare Ansicht <em>neben</em> dem Original, nie stattdessen.</li>
</ul>



<p class="wp-block-paragraph">Für das <strong>Ausstellen</strong> gibt es Übergangsfristen: Papier- und einfache PDF-Rechnungen sind noch bis Ende 2026 erlaubt, für Unternehmen mit einem Vorjahresumsatz bis 800.000 Euro bis Ende 2027. Danach müssen Rechnungen zwischen inländischen Unternehmen E-Rechnungen sein. Kleinbetragsrechnungen bis 250 Euro und Kleinunternehmer haben Ausnahmen; frag deine Steuerberatung, welche für dich gelten.</p>



<h2 class="wp-block-heading">3. Sortieren: Tags, Korrespondenten und ein bisschen KI</h2>



<p class="wp-block-paragraph">Die Suche funktioniert nur, wenn Dokumente einheitlich sortiert sind. Paperless bietet vier Werkzeuge. Nutze sie für unterschiedliche Dinge:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Feld</th><th>Wofür</th><th>Beispiel</th></tr></thead><tbody><tr><td>Korrespondent</td><td>Wer hat es geschickt</td><td>Telefonanbieter, Versicherung, Finanzamt</td></tr><tr><td>Dokumenttyp</td><td>Was es ist</td><td>Rechnung, Vertrag, Kontoauszug, Brief</td></tr><tr><td>Tags</td><td>Status und Zweck</td><td><code>business</code>, <code>to-review</code>, <code>for-tax-advisor</code></td></tr><tr><td>Benutzerdefinierte Felder</td><td>Strukturierte Daten</td><td>Betrag, Zielordner, „Aufbewahren bis“-Jahr</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Paperless lernt aus deiner Sortierung und schlägt für neue Dokumente Korrespondenten und Tags vor. Wir haben einen kleinen Klassifizierer dazugebaut, der ein Sprachmodell nach dem passenden Zielordner fragt. Wenn du das auch machst, achte auf den Datenschutz: Unser Klassifizierer schickt nur den ersten Teil des Textes, und er <strong>maskiert personenbezogene Daten vor dem Versand</strong>: Namen, Adressen, IBANs, Steuer- und Kundennummern und alle Geldbeträge. Dokumente, bei denen er unsicher ist, bekommen ein Tag <code>to-review</code>, statt automatisch einsortiert zu werden.</p>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Halte die Liste der Tags und Ordner kurz und schreib sie auf. Zwanzig klar definierte Ordner sind besser als hundert, die jeder anders auslegt.</p>
</div>



<h2 class="wp-block-heading">4. Abläufe für die Menschen, die damit arbeiten</h2>



<p class="wp-block-paragraph">Die Technik ist der einfache Teil. Das papierlose Büro funktioniert nur, wenn alle denselben Abläufen folgen. Diese Abläufe funktionieren gut für ein kleines Büro; pass sie an deine Situation an:</p>



<h3 class="wp-block-heading">Eingehendes Papier (täglich)</h3>



<ol class="wp-block-list">
<li>Post öffnen, Heftklammern entfernen, alles in den Dokumenteneinzug legen.</li>



<li>Mit Duplex und 300 dpi scannen. Ein Dokument pro Scanauftrag, oder Trennblätter verwenden.</li>



<li>Das Ergebnis noch am selben Tag in Paperless prüfen: Ist jede Seite lesbar, ist der OCR-Text plausibel, hat die Sortierung funktioniert?</li>



<li>Das Papier in eine Kiste für den laufenden Monat legen. Dort bleibt es bis zur Monatsprüfung (siehe unten), nicht länger.</li>
</ol>



<h3 class="wp-block-heading">Rechnungen und Dokumente per E-Mail</h3>



<ul class="wp-block-list">
<li>Nutze ein eigenes Postfach nur für Rechnungen und gib diese Adresse allen Lieferanten.</li>



<li>Lass Paperless die Anhänge mit seinem eingebauten Mail-Import automatisch abholen und zur Prüfung markieren.</li>



<li>Druck eine E-Mail-Rechnung nie aus. Die Datei ist das Original.</li>
</ul>



<h3 class="wp-block-heading">Belege unterwegs</h3>



<p class="wp-block-paragraph">Tanken, Parken, Büromaterial: Fotografier die Quittung noch am selben Tag mit dem Handy und lade sie hoch, zum Beispiel über eine Paperless-App oder einen geteilten Nextcloud-Ordner, den Paperless einliest. Leg den Papierbeleg trotzdem in die Monatskiste, denn Thermopapier verblasst und ein schlechtes Foto ist wertlos.</p>



<h3 class="wp-block-heading">Monatlicher Ablauf</h3>



<ol class="wp-block-list">
<li>Alles mit dem Tag <code>to-review</code> abarbeiten.</li>



<li>Die Kontoauszüge des Monats mit den abgelegten Rechnungen vergleichen: Gibt es zu jeder Buchung einen Beleg?</li>



<li>Den Monat an die Steuerberatung übergeben (ein geteilter Ordner oder ein Export aller Dokumente mit <code>for-tax-advisor</code>).</li>



<li>Eine Stichprobe der gescannten Papiere mit den Originalen vergleichen, die Prüfung im Protokoll vermerken und das Papier dann vernichten. Die Ausnahmen aus Abschnitt 6 behältst du.</li>
</ol>



<h3 class="wp-block-heading">Rollen und Berechtigungen</h3>



<p class="wp-block-paragraph">Gib jeder Person einen eigenen Login. Geschäftsdokumente gehören der Inhaberin oder dem Inhaber; private Haushaltsdokumente können für die ganze Familie sichtbar sein. Niemand außer der Administration darf Dokumente löschen, und auch dann sollte das Löschen über einen Papierkorb mit Wartezeit laufen.</p>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Achtung:</strong> In Paperless gehören Tags, Korrespondenten und Dokumenttypen, die mit dem Administrator-Konto angelegt wurden, dem Administrator. Andere Benutzer sehen sie dann nicht, und ihre Uploads bleiben ohne Tags. Leg gemeinsame Objekte ohne Eigentümer an oder vergib ausdrücklich Berechtigungen.</p>
</div>



<h2 class="wp-block-heading">5. Ordnungsmäßigkeit: GoBD, Verfahrensdokumentation und Datenschutz</h2>



<p class="wp-block-paragraph">In Deutschland folgen digitale Buchführung und Archivierung den GoBD, den Grundsätzen der Finanzverwaltung zur ordnungsmäßigen Führung und Aufbewahrung von Büchern, Aufzeichnungen und Unterlagen in elektronischer Form. In der Praxis laufen sie auf ein paar Anforderungen hinaus:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Anforderung</th><th>Was sie bedeutet</th><th>Wie wir sie abdecken</th></tr></thead><tbody><tr><td>Vollständigkeit</td><td>Jedes relevante Dokument wird erfasst</td><td>Monatlicher Abgleich von Kontoauszügen und Rechnungen</td></tr><tr><td>Unveränderbarkeit</td><td>Originale lassen sich nicht unbemerkt ändern</td><td>Paperless speichert die Originaldatei mit Prüfsumme; geändert werden nur Metadaten; nächtliche Snapshots</td></tr><tr><td>Nachvollziehbarkeit</td><td>Es ist klar, wer was wann getan hat</td><td>Persönliche Logins, Audit-Log von Paperless, Scanprotokoll</td></tr><tr><td>Verfügbarkeit</td><td>Dokumente können während der ganzen Aufbewahrungsfrist vorgelegt und ausgewertet werden</td><td>Backups, Kopie außer Haus, Exportfunktion, offene Formate (PDF/A, XML)</td></tr><tr><td>Maschinelle Auswertbarkeit</td><td>Digitale Originale bleiben im Ursprungsformat</td><td>E-Rechnungen und PDFs werden so gespeichert, wie sie ankamen</td></tr><tr><td>Dokumentiertes Verfahren</td><td>Es gibt eine schriftliche Verfahrensbeschreibung</td><td>Siehe unten</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Sei in einem Punkt ehrlich: Ein selbst gehostetes Paperless-ngx ist kein zertifiziertes „revisionssicheres“ Archiv. Die Anforderungen deckst du über die Art ab, wie du es betreibst: eingeschränkte Löschrechte, Protokollierung, Backups und ein dokumentiertes Verfahren. Besprich mit deiner Steuerberatung, ob das für deinen Betrieb ausreicht.</p>



<h3 class="wp-block-heading">Die Verfahrensdokumentation</h3>



<p class="wp-block-paragraph">Dieses Dokument macht das Vernichten von Papier nach dem Scannen (ersetzendes Scannen) überhaupt erst möglich. Schreib es also <em>bevor</em> du das erste Original in den Schredder steckst. Es beschreibt in einfachen Worten:</p>



<ul class="wp-block-list">
<li>welche Dokumente gescannt werden, von wem, wann und mit welchem Gerät und welchen Einstellungen;</li>



<li>wie der Scan auf Vollständigkeit und Lesbarkeit geprüft wird und wer prüft;</li>



<li>wie E-Mails und E-Rechnungen empfangen, geprüft und archiviert werden;</li>



<li>wann und wie Papieroriginale vernichtet werden und welche aufbewahrt werden;</li>



<li>das System: Software, Versionen, Speicher, Zugriffsrechte, Backups und wie eine Wiederherstellung funktioniert;</li>



<li>wer verantwortlich ist und wann das Dokument zuletzt geprüft wurde.</li>
</ul>



<p class="wp-block-paragraph">Prüf sie einmal im Jahr und jedes Mal, wenn sich etwas ändert, etwa ein neuer Scanner, eine neue Softwareversion oder eine neue Person in der Buchhaltung. Die Verfahrensdokumentation selbst musst du so lange aufbewahren wie die Dokumente, die sie beschreibt.</p>



<h3 class="wp-block-heading">Datenschutz (DSGVO)</h3>



<ul class="wp-block-list">
<li>Das Archiv enthält personenbezogene Daten von Kunden, Lieferanten und Familienmitgliedern. Beschränk den Zugriff auf die Personen, die ihn brauchen.</li>



<li>Jeder externe Dienst, der Dokumente sieht (Cloud-Speicher, ein KI-Klassifizierer, ein OCR-Dienst), braucht einen Vertrag zur Auftragsverarbeitung, und personenbezogene Daten sollten maskiert werden, bevor sie das Haus verlassen.</li>



<li>Aufbewahrungsfristen sind auch Löschfristen: Sobald ein Dokument steuer- und handelsrechtlich nicht mehr gebraucht wird, sollten die personenbezogenen Daten darin gelöscht werden.</li>
</ul>



<h2 class="wp-block-heading">6. Wie lange was aufbewahrt werden muss</h2>



<p class="wp-block-paragraph">Die Abgabenordnung (§ 147 AO) und das Handelsgesetzbuch (§ 257 HGB) legen die Fristen fest. Seit 2025 ist die Frist für Buchungsbelege kürzer als früher:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Frist</th><th>Unterlagen</th></tr></thead><tbody><tr><td><strong>10 Jahre</strong></td><td>Bücher und Aufzeichnungen, Inventare, Eröffnungsbilanzen, Jahresabschlüsse, Lageberichte sowie die zu ihrem Verständnis erforderlichen Arbeitsanweisungen und Organisationsunterlagen (einschließlich der Verfahrensdokumentation)</td></tr><tr><td><strong>8 Jahre</strong></td><td>Buchungsbelege: Eingangs- und Ausgangsrechnungen, Quittungen, Kontoauszüge, Lohnbelege, Gutschriften (verkürzt von 10 Jahren; gilt für alle Belege, deren Frist zu Beginn 2025 noch nicht abgelaufen war)</td></tr><tr><td><strong>6 Jahre</strong></td><td>Empfangene und abgesandte Handels- und Geschäftsbriefe (einschließlich E-Mails, die wie Briefe wirken, etwa Bestellungen und Auftragsbestätigungen) und sonstige steuerlich relevante Unterlagen</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Drei Details, die oft falsch verstanden werden:</p>



<ul class="wp-block-list">
<li><strong>Die Frist beginnt mit dem Ende des Kalenderjahres</strong>, in dem das Dokument entstanden ist oder empfangen wurde. Eine Rechnung vom März 2026 muss bis Ende 2034 aufbewahrt werden (8 Jahre ab dem 31. Dezember 2026).</li>



<li><strong>Offene Steuerfestsetzungen verlängern sie:</strong> Solange die Steuerfestsetzung für ein Jahr noch offen ist (zum Beispiel während eines Einspruchs oder einer Betriebsprüfung), dürfen die Unterlagen dafür nicht vernichtet werden, auch wenn die Frist abgelaufen ist.</li>



<li><strong>Manche Branchen weichen ab:</strong> Für Banken, Versicherungen und Wertpapierinstitute gelten andere Fristen. Prüf, ob dich das betrifft.</li>
</ul>



<p class="wp-block-paragraph">Manche Papieroriginale solltest du auch mit gutem Verfahren nach dem Scannen nie vernichten: notarielle Urkunden, Jahresabschlüsse und Eröffnungsbilanzen mit Originalunterschrift, Zollbelege und alles, bei dem das Original selbst rechtlichen Wert hat (zum Beispiel Urkunden oder Dokumente mit Siegel). Bewahr sie in einem Ordner auf und scanne sie nur zur Bequemlichkeit.</p>



<p class="wp-block-paragraph">Eine einfache Lösung dafür in Paperless: ein benutzerdefiniertes Feld „Aufbewahren bis“ (ein Jahr) bei jedem Dokumenttyp mit Aufbewahrungsfrist und eine gespeicherte Ansicht, die alles auflistet, dessen Jahr abgelaufen ist. Einmal im Jahr entscheidest du, was gelöscht wird. Lass nichts automatisch löschen.</p>



<h2 class="wp-block-heading">7. Einführungsplan</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Phase</th><th>Dauer</th><th>Was passiert</th></tr></thead><tbody><tr><td>1. Planen</td><td>1 Woche</td><td>Ziele, Ordnerliste, Tags und Rollen festlegen; mit der Steuerberatung sprechen</td></tr><tr><td>2. Aufbauen</td><td>1–2 Wochen</td><td>Server, Paperless, Scanner, Mail-Import und Backups einrichten; eine Wiederherstellung testen</td></tr><tr><td>3. Pilot</td><td>1 Monat</td><td>Alles Neue scannen, sämtliches Papier behalten, Sortierung und Abläufe nachschärfen</td></tr><tr><td>4. Dokumentieren</td><td>während des Pilots</td><td>Verfahrensdokumentation schreiben und prüfen lassen</td></tr><tr><td>5. Start</td><td>ab Monat 2</td><td>Papier nach der Monatsprüfung vernichten (außer den Ausnahmen)</td></tr><tr><td>6. Altbestand</td><td>nach Zeit</td><td>Alte Ordner vom neuesten Jahr rückwärts scannen; was über der Frist ist, kann direkt weg</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">8. Checkliste</h2>



<ul class="wp-block-list">
<li>Archiv nur mit Login erreichbar, am besten Single Sign-on, nicht offen im Internet</li>



<li>Nächtliches Backup, Kopie außer Haus, Wiederherstellung mindestens einmal getestet</li>



<li>Eigenes Rechnungspostfach mit automatischem Import</li>



<li>E-Rechnungen als Original-XML bzw. ZUGFeRD-PDF gespeichert, Viewer vorhanden</li>



<li>Schriftliche Ordner- und Tag-Liste</li>



<li>Verfahrensdokumentation geschrieben und von der Steuerberatung geprüft</li>



<li>Monatlicher Prüfablauf mit Scan-Kontrolle und Protokoll</li>



<li>Aufbewahrungsfeld und jährliche Löschprüfung</li>



<li>Kiste für Papieroriginale, die aufbewahrt werden müssen</li>
</ul>



<h2 class="wp-block-heading">Was ich gelernt habe</h2>



<ul class="wp-block-list">
<li>Das Schwierige ist nicht die Software, sondern die Routine. Plane jeden Tag und jeden Monat eine feste Zeit dafür ein.</li>



<li>Schreib die Verfahrensdokumentation früh. Sie zwingt dich, Dinge zu entscheiden, die du sonst offen lassen würdest.</li>



<li>Maskiere personenbezogene Daten, bevor Dokumenttext das Haus verlässt, auch für „nur eine schnelle KI-Sortierung“.</li>



<li>Prüf Eigentümer und Berechtigungen mit einem zweiten Benutzerkonto, bevor du startest. Viele „bei ihr funktioniert es nicht“-Probleme waren Berechtigungsprobleme.</li>



<li>Löschen an einer Stelle sollte überall löschen. Wir gleichen den Paperless-Papierkorb mit der Nextcloud-Kopie ab, sonst leben alte Kopien weiter.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Heizungsautomationen in Home Assistant: Logik pro Raum, TRV-Kalibrierung und unsere Probleme</title>
		<link>https://selfhostjournal.eu/de/home-assistant-heizung-trv-kalibrierung/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Thu, 22 Oct 2026 19:18:42 +0000</pubDate>
				<category><![CDATA[Home Assistant]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=92</guid>

					<description><![CDATA[Zwölf SONOFF-TRVZB-Ventile in neun Räumen: Toggle, Target Sync und Calibration pro Raum, eine Tür- und Fensterpause, Sicherheitsnetze und die Probleme, aus denen jedes davon entstanden ist.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">Kurz gesagt</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Heizkörperthermostate messen die Temperatur direkt neben dem heißen Heizkörper, ein gemeinsamer Blueprint reagierte nicht mehr, und ein Update hat die Heizung unbemerkt lahmgelegt.</p>



<p class="wp-block-paragraph"><strong>Lösung:</strong> Drei kleine Automationen pro Raum (Toggle, Target Sync, Calibration), ein echter Raumsensor als Messwert für jedes Ventil und ein paar Sicherheitsnetze, die melden, wenn etwas aus dem Ruder läuft.</p>



<p class="wp-block-paragraph"><strong>Getestet mit:</strong> Home Assistant, Zigbee2MQTT 2.14, 12 Heizkörperventile SONOFF TRVZB, Zigbee-Temperatur- und Bewegungssensoren</p>
</div>



<p class="wp-block-paragraph">Unser Haus hat neun beheizte Räume mit zwölf smarten Heizkörperventilen. Das Ziel war einfach: warm, wenn jemand im Raum ist, ein paar Grad weniger, wenn nicht, und keine Heizung bei offenem Fenster. Bis dahin hat es ein paar Umbauten gebraucht. Hier ist das Setup, das jetzt funktioniert, und die Probleme, auf die wir unterwegs gestoßen sind.</p>



<h2 class="wp-block-heading">Die Bausteine pro Raum</h2>



<p class="wp-block-paragraph">Jeder Raum nutzt dieselben Helfer. Dadurch sind die Automationen fast identisch, und ein neuer Raum ist im Wesentlichen kopieren und umbenennen.</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Helfer</th><th>Zweck</th></tr></thead><tbody><tr><td><code>input_boolean.heating_office_on</code></td><td>Hauptschalter für den Raum (Dashboard, Telegram, Skripte „alle an/aus“)</td></tr><tr><td><code>input_number.temperature_comfort_office</code></td><td>Komforttemperatur, zum Beispiel 21 °C</td></tr><tr><td><code>input_number.temperature_eco_office</code></td><td>ECO-Temperatur, zum Beispiel 19 °C</td></tr><tr><td><code>schedule.schedule_heating_office</code></td><td>Wann der Raum überhaupt geheizt werden soll</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Dazu hat jeder Raum drei Automationen:</p>



<ul class="wp-block-list">
<li><strong>Toggle</strong> folgt dem Hauptschalter und schaltet Ventil und Target Sync ein oder aus.</li>



<li><strong>Target Sync</strong> entscheidet zwischen Komfort und ECO und schreibt die Zieltemperatur ans Ventil.</li>



<li><strong>Calibration</strong> gibt die echte Raumtemperatur an das Ventil weiter.</li>
</ul>



<h2 class="wp-block-heading">Warum ich den Blueprint ersetzt habe</h2>



<p class="wp-block-paragraph">Am Anfang liefen alle Räume über einen gemeinsamen Heizungs-Blueprint. Das war bequem, bis es das nicht mehr war: Ende August reagierte er auf einige Eingaben einfach nicht mehr, weil eine interne Schutzbedingung fehlschlug. Ein Fehler in einem gemeinsamen Blueprint trifft alle Räume gleichzeitig, und die Logik lässt sich von außen schlecht untersuchen.</p>



<p class="wp-block-paragraph">Ich habe ihn durch drei einfache Automationen pro Raum ersetzt. Das sind mehr Automationen (33 für das ganze Haus), aber jede ist kurz, macht genau eine Sache und ist im Trace gut zu verfolgen, wenn etwas schiefgeht.</p>



<h2 class="wp-block-heading">1. Toggle</h2>



<p class="wp-block-paragraph">Geht der Hauptschalter an, wird das Ventil auf <code>heat</code> gesetzt, der Target Sync eingeschaltet und einmal ausgeführt, damit sofort die richtige Temperatur gilt. Geht er aus, wird zuerst der Target Sync abgeschaltet und dann das Ventil ausgeschaltet.</p>



<pre class="wp-block-code"><code>alias: Office - Heating - Toggle
triggers:
  - trigger: state
    entity_id: input_boolean.heating_office_on
    to: "on"
    id: turned_on
  - trigger: state
    entity_id: input_boolean.heating_office_on
    to: "off"
    id: turned_off
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: turned_on
        sequence:
          - action: climate.set_hvac_mode
            target:
              entity_id: climate.office_heating_valve
            data:
              hvac_mode: heat
          - action: automation.turn_on
            target:
              entity_id: automation.office_heating_target_sync
          - action: automation.trigger
            target:
              entity_id: automation.office_heating_target_sync
      - conditions:
          - condition: trigger
            id: turned_off
        sequence:
          - action: automation.turn_off
            target:
              entity_id: automation.office_heating_target_sync
          - action: climate.set_hvac_mode
            target:
              entity_id: climate.office_heating_valve
            data:
              hvac_mode: "off"
mode: single</code></pre>



<h2 class="wp-block-heading">2. Target Sync</h2>



<p class="wp-block-paragraph">Beim Target Sync unterscheiden sich die Räume. Im Büro folgt er einem Bewegungsmelder: Komfort nach zwei Minuten Bewegung, ECO nach zehn Minuten ohne. Die Verzögerungen verhindern, dass die Heizung hoch- und runterspringt, wenn jemand nur kurz durchläuft.</p>



<pre class="wp-block-code"><code>alias: Office - Heating - Target Sync
triggers:
  - trigger: state
    entity_id: binary_sensor.office_motion
    to: "on"
    for: { minutes: 2 }
  - trigger: state
    entity_id: binary_sensor.office_motion
    to: "off"
    for: { minutes: 10 }
  - trigger: state
    entity_id:
      - input_number.temperature_comfort_office
      - input_number.temperature_eco_office
  - trigger: state
    entity_id: input_boolean.heating_office_on
    to: "on"
conditions:
  - condition: state
    entity_id: input_boolean.heating_office_on
    state: "on"
actions:
  - if:
      - condition: state
        entity_id: binary_sensor.office_motion
        state: "on"
    then:
      - action: climate.set_temperature
        target:
          entity_id: climate.office_heating_valve
        data:
          temperature: "{{ states('input_number.temperature_comfort_office') | float }}"
    else:
      - action: climate.set_temperature
        target:
          entity_id: climate.office_heating_valve
        data:
          temperature: "{{ states('input_number.temperature_eco_office') | float }}"
mode: restart</code></pre>



<p class="wp-block-paragraph">Andere Räume nutzen andere Signale nach demselben Muster:</p>



<ul class="wp-block-list">
<li><strong>Nur Zeitplan</strong> (Küche, Schlafzimmer, Bad): Komfort innerhalb des Zeitplans, ECO außerhalb.</li>



<li><strong>Zeitplan und Anwesenheit</strong> (Räume, die einer Person gehören): Komfort nur, wenn der Zeitplan aktiv ist <em>und</em> diese Person zu Hause ist.</li>



<li><strong>Ein Gerät als Anwesenheit</strong>: Im Gaming-Raum ist der AV-Receiver das beste Zeichen, dass jemand da ist. Receiver an heißt Komfort, Receiver aus heißt abgesenkte Temperatur.</li>
</ul>



<h2 class="wp-block-heading">3. Calibration: der wichtigste Teil</h2>



<p class="wp-block-paragraph">Ein Heizkörperthermostat misst die Temperatur dort, wo es sitzt: direkt neben dem heißen Heizkörper. Es hält den Raum also für warm, lange bevor er es ist, und schließt zu früh.</p>



<p class="wp-block-paragraph">Das SONOFF TRVZB kann stattdessen eine externe Temperatur verwenden. In Zigbee2MQTT gibt es dafür zwei Entitäten:</p>



<ul class="wp-block-list">
<li>ein <strong>Select</strong> „Temperature sensor“: <code>local_temperature</code> (der eigene Sensor) oder <code>remote_temperature</code> (ein externer Wert)</li>



<li>eine <strong>Number</strong> „External temperature input“, die den externen Wert empfängt</li>
</ul>



<p class="wp-block-paragraph">Die Calibration-Automation kopiert den Wert eines echten Raumsensors in diesen Eingang, sobald die Messung zwei Minuten stabil ist. Außerdem prüft sie das Select, denn nach einem Neuverbinden oder Batteriewechsel fällt das Ventil manchmal auf seinen eigenen Sensor zurück.</p>



<pre class="wp-block-code"><code>alias: Office - Climate - Calibration
triggers:
  - trigger: state
    entity_id: sensor.office_temperature
    for: { minutes: 2 }
  - trigger: state
    entity_id: select.office_heating_valve_temperature_sensor_select
    not_to:
      - remote_temperature
      - remote_source_offline
      - unavailable
      - unknown
conditions:
  - condition: numeric_state
    entity_id: sensor.office_temperature
    above: 0
actions:
  # das Ventil wieder auf den externen Sensor stellen, falls es zurückgefallen ist
  - if:
      - condition: not
        conditions:
          - condition: state
            entity_id: select.office_heating_valve_temperature_sensor_select
            state:
              - remote_temperature
              - remote_source_offline
    then:
      - action: select.select_option
        target:
          entity_id: select.office_heating_valve_temperature_sensor_select
        data:
          option: remote_temperature
        continue_on_error: true
  # die echte Raumtemperatur an das Ventil geben
  - action: number.set_value
    target:
      entity_id: number.office_heating_valve_external_temperature_input
    data:
      value: "{{ states('sensor.office_temperature') | float }}"
mode: single</code></pre>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Lass <code>remote_source_offline</code> in Ruhe. Das ist die eigene Rückfallebene des Ventils, wenn der externe Wert veraltet ist. Erzwingst du dann wieder <code>remote_temperature</code>, heizt das Ventil mit einem alten Wert.</p>
</div>



<p class="wp-block-paragraph">Die Bedingung <code>above: 0</code> verhindert, dass ein Sensor, der 0 oder gar nichts meldet, das Haus herunterkühlt. Mit der Calibration erreicht der Raum endlich die Temperatur, die das Dashboard anzeigt.</p>



<h2 class="wp-block-heading">4. Keine Heizung bei offener Tür</h2>



<p class="wp-block-paragraph">Das Wohnzimmer hat eine Terrassentür und zwei Fenster, und die Heizung lief weiter, wenn sie offen waren. Die Pause-Automation schaltet die drei Ventile aus, wenn eine Tür oder ein Fenster eine Minute offen ist, und schaltet sie wieder ein, wenn <em>alle</em> wieder geschlossen sind.</p>



<p class="wp-block-paragraph">Das wichtige Detail: Sie fasst den Hauptschalter <strong>nicht</strong> an. Stattdessen setzt sie ein eigenes Flag (<code>input_boolean.heating_living_room_paused</code>). Toggle und Target Sync lassen die Ventile in Ruhe, solange das Flag an ist. Weil das Flag ein Helfer ist und kein Warten innerhalb der Automation, übersteht es auch einen Neustart von Home Assistant. Ein Start-Trigger stellt die Heizung wieder her, falls alles geschlossen wurde, während Home Assistant nicht lief.</p>



<pre class="wp-block-code"><code>triggers:
  - trigger: state
    entity_id: [binary_sensor.terrace_door, binary_sensor.living_room_window_1, binary_sensor.living_room_window_2]
    to: "on"
    for: { minutes: 1 }
    id: opened
  - trigger: state
    entity_id: [binary_sensor.terrace_door, binary_sensor.living_room_window_1, binary_sensor.living_room_window_2]
    from: "on"
    to: "off"
    id: closed
  - trigger: homeassistant
    event: start
    id: ha_start
actions:
  - choose:
      - conditions:   # geöffnet, während die Heizung an ist -> pausieren
          - condition: trigger
            id: opened
          - condition: state
            entity_id: input_boolean.heating_living_room_on
            state: "on"
        sequence:
          - action: input_boolean.turn_on
            target: { entity_id: input_boolean.heating_living_room_paused }
          - action: climate.set_hvac_mode
            target: { entity_id: [climate.living_room_heating_1, climate.living_room_heating_2, climate.living_room_heating_3] }
            data: { hvac_mode: "off" }
      - conditions:   # alles zu und von uns pausiert -> fortsetzen
          - condition: trigger
            id: [closed, ha_start]
          - condition: state
            entity_id: [binary_sensor.terrace_door, binary_sensor.living_room_window_1, binary_sensor.living_room_window_2]
            state: "off"
          - condition: state
            entity_id: input_boolean.heating_living_room_paused
            state: "on"
        sequence:
          - action: input_boolean.turn_off
            target: { entity_id: input_boolean.heating_living_room_paused }
          - if:
              - condition: state
                entity_id: input_boolean.heating_living_room_on
                state: "on"
            then:
              - action: climate.set_hvac_mode
                target: { entity_id: [climate.living_room_heating_1, climate.living_room_heating_2, climate.living_room_heating_3] }
                data: { hvac_mode: heat }
              - action: automation.trigger
                target: { entity_id: automation.living_room_heating_target_sync }
mode: queued</code></pre>



<h2 class="wp-block-heading">5. Sicherheitsnetze</h2>



<p class="wp-block-paragraph">Automationen scheitern leise. Diese drei sorgen dafür, dass ich es mitbekomme:</p>



<ul class="wp-block-list">
<li><strong>Kindersicherung erzwingen:</strong> Nach einem Batteriewechsel oder Neu-Anlernen kann die Kindersicherung eines Ventils aus sein, und dann kann jeder am Rädchen drehen. Ist eine der zwölf Sperren 10 Sekunden lang aus, oder startet Home Assistant, werden alle Sperren wieder eingeschaltet.</li>



<li><strong>Warnung bei falscher Zieltemperatur:</strong> 10 Minuten nachdem sich die Zieltemperatur eines Ventils oder ein Komfort-/ECO-Wert geändert hat, prüft ein Template jedes Ventil. Passt eine Zieltemperatur weder zum ECO- noch zum Komfortwert ihres Raums (Toleranz 0,25 °C), bekomme ich eine Telegram-Nachricht. Räume mit ausgeschalteter Heizung, ausgeschaltete Ventile und das pausierte Wohnzimmer werden übersprungen. So fällt ein Target Sync auf, der das Ventil nicht erreicht hat.</li>



<li><strong>Warnung bei neuer Zigbee2MQTT-Version:</strong> eine Nachricht, sobald Zigbee2MQTT in einer neuen Version läuft. Den Grund liest du im nächsten Abschnitt.</li>
</ul>



<p class="wp-block-paragraph">Der Kern der Prüfung, vereinfacht auf ein Ventil:</p>



<pre class="wp-block-code"><code>{% set target  = state_attr('climate.office_heating_valve', 'temperature') | float %}
{% set eco     = states('input_number.temperature_eco_office') | float %}
{% set comfort = states('input_number.temperature_comfort_office') | float %}
{{ (target - eco) | abs &gt; 0.25 and (target - comfort) | abs &gt; 0.25 }}</code></pre>



<h2 class="wp-block-heading">Die Probleme, die wir hatten</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Problem</th><th>Ursache</th><th>Lösung</th></tr></thead><tbody><tr><td>Räume reagierten nicht auf Zeitplan- oder Bewegungsänderungen</td><td>Die interne Schutzbedingung des gemeinsamen Heizungs-Blueprints schlug fehl</td><td>Drei eigene Automationen pro Raum statt des Blueprints</td></tr><tr><td>Räume blieben kälter als die Zieltemperatur</td><td>Das Ventil maß direkt neben dem Heizkörper</td><td>Calibration mit einem echten Raumsensor</td></tr><tr><td>Ventil fiel auf den eigenen Sensor zurück</td><td>Neuverbinden oder Batteriewechsel setzt das Select zurück</td><td>Calibration reagiert auch auf das Select und stellt es zurück</td></tr><tr><td>Heizung lief bei offener Terrassentür</td><td>Keine Pause-Logik</td><td>Tür- und Fensterpause mit eigenem Flag</td></tr><tr><td>Calibration funktionierte unbemerkt nicht mehr</td><td>Ein automatisches Update von Zigbee2MQTT 2.14.1 auf 2.14.2 hat die Sensor-Optionen des TRVZB umbenannt</td><td>Automationen auf die neuen Optionsnamen umgestellt, dazu eine Warnung bei Versionswechsel</td></tr><tr><td>Ventile ließen sich von Hand verstellen</td><td>Kindersicherung nach Batteriewechsel oder Neu-Anlernen aus</td><td>Kindersicherung wird erzwungen</td></tr><tr><td>Ein Ventil stand unbemerkt auf einer falschen Temperatur</td><td>Ein Target Sync, der das Ventil nicht erreicht hat</td><td>Warnung bei falscher Zieltemperatur</td></tr></tbody></table></figure>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Achtung:</strong> Läuft Zigbee2MQTT mit dem Image <code>latest</code> und aktualisiert sich selbst, kann eine Änderung im Konverter über Nacht Entitäten oder Optionen umbenennen. Es gibt keinen Fehler, die Heizung tut einfach still und leise nicht mehr, was du denkst. Entweder die Version festlegen und bewusst aktualisieren, oder dich zumindest bei einem Versionswechsel benachrichtigen lassen.</p>
</div>



<h2 class="wp-block-heading">Was ich gelernt habe</h2>



<ul class="wp-block-list">
<li>Kleine, langweilige Automationen pro Raum schlagen einen cleveren gemeinsamen Blueprint. Sie sind leichter zu lesen, zu testen und zu reparieren.</li>



<li>Ein Heizkörperthermostat ohne externen Sensor heizt auf die falsche Temperatur. Ihm einen echten Raumsensor zu geben, ist die zusätzliche Automation wert.</li>



<li>Nutze ein Flag als Helfer statt eines Wartens für alles, was einen Neustart überstehen muss.</li>



<li>Jede Automation, die still scheitern kann, braucht eine Warnung, die das Ergebnis prüft, nicht nur den Auslöser.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Single Sign-on für WordPress mit Authentik</title>
		<link>https://selfhostjournal.eu/de/wordpress-sso-mit-authentik/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Thu, 22 Oct 2026 16:53:17 +0000</pubDate>
				<category><![CDATA[Sicherheit und SSO]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/wordpress-sso-mit-authentik/</guid>

					<description><![CDATA[Alle Einstellungen auf beiden Seiten: Provider und Anwendung in Authentik, alle Werte für das Plugin OpenID Connect Generic, ein curl-Test und eine Tabelle zur Fehlersuche.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">Kurz gesagt</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> WordPress war der letzte Dienst, der noch ein eigenes Passwort wollte.</p>



<p class="wp-block-paragraph"><strong>Lösung:</strong> Ein OAuth2/OpenID-Provider in Authentik plus das Plugin OpenID Connect Generic in WordPress, mit allen Werten unten aufgelistet.</p>



<p class="wp-block-paragraph"><strong>Getestet mit:</strong> WordPress 7.1, Authentik 2026.8, OpenID Connect Generic 3.11</p>
</div>



<p class="wp-block-paragraph">Ich nutze Authentik als zentrale Anmeldung für meine selbst gehosteten Dienste. Dieser Blog soll genauso funktionieren: ein Klick auf den Login-Button, Passkey, fertig. Die normale WordPress-Anmeldung bleibt als Rückfallebene erhalten, falls Authentik einmal nicht erreichbar ist.</p>



<p class="wp-block-paragraph">In den Beispielen läuft Authentik unter <code>https://auth.example.com</code>, der Blog unter <code>https://blog.example.com</code>, und der Slug der Authentik-Anwendung ist <code>blog</code>. Ersetze diese Werte durch deine eigenen.</p>



<h2 class="wp-block-heading">So funktioniert die Anmeldung</h2>



<ol class="wp-block-list">
<li>Du klickst auf der WordPress-Anmeldeseite auf den Login-Button.</li>



<li>WordPress leitet dich zu Authentik weiter, wo du dich anmeldest, zum Beispiel mit einem Passkey.</li>



<li>Authentik schickt dich mit einem Einmal-Code zurück zu WordPress.</li>



<li>WordPress tauscht den Code im Hintergrund gegen Tokens, liest Benutzername und E-Mail-Adresse und meldet dich als passenden WordPress-Benutzer an.</li>
</ol>



<h2 class="wp-block-heading">1. Authentik: Provider anlegen</h2>



<p class="wp-block-paragraph">Im Authentik-Adminbereich unter <strong>Applications → Providers → Create</strong> den Typ <strong>OAuth2/OpenID Provider</strong> wählen. Das sind meine Einstellungen:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Einstellung</th><th>Wert</th></tr></thead><tbody><tr><td>Name</td><td>Provider for Blog</td></tr><tr><td>Authorization flow</td><td><code>default-provider-authorization-implicit-consent</code> (ohne zusätzliche „Zugriff erlauben?“-Seite)</td></tr><tr><td>Invalidation flow</td><td><code>default-provider-invalidation-flow</code></td></tr><tr><td>Client type</td><td>Confidential</td></tr><tr><td>Client ID / Client Secret</td><td>erzeugt Authentik, beide für WordPress kopieren</td></tr><tr><td>Redirect URI (strict)</td><td><code>https://blog.example.com/wp-admin/admin-ajax.php?action=openid-connect-authorize</code></td></tr><tr><td>Signing key</td><td>authentik Self-signed Certificate</td></tr><tr><td>Scopes</td><td>die Standard-Mappings für <code>openid</code>, <code>email</code> und <code>profile</code></td></tr><tr><td>Subject mode</td><td>Based on the User&#8217;s hashed ID</td></tr><tr><td>Include claims in id_token</td><td>an</td></tr><tr><td>Issuer mode</td><td>Each provider has a different issuer</td></tr><tr><td>Grant types</td><td>Authorization code und Refresh token</td></tr><tr><td>Access token validity</td><td>5 Minuten</td></tr><tr><td>Refresh token validity</td><td>30 Tage</td></tr></tbody></table></figure>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Achtung:</strong> Wenn du den Provider über die Authentik-API statt über die Oberfläche anlegst, musst du <code>grant_types</code> selbst setzen. Sonst bleibt die Liste leer und jede Anmeldung endet mit „The request is otherwise malformed“. Im Authentik-Serverlog steht dann „Invalid grant_type for provider“.</p>
</div>



<p class="wp-block-paragraph">Zum Vergleich derselbe Provider, über die API angelegt:</p>



<pre class="wp-block-code"><code>curl -X POST https://auth.example.com/api/v3/providers/oauth2/ 
  -H "Authorization: Bearer $AUTHENTIK_TOKEN" 
  -H "Content-Type: application/json" 
  -d '{
    "name": "Provider for Blog",
    "client_type": "confidential",
    "authorization_flow": "&lt;UUID von default-provider-authorization-implicit-consent&gt;",
    "invalidation_flow": "&lt;UUID von default-provider-invalidation-flow&gt;",
    "property_mappings": ["&lt;openid-UUID&gt;", "&lt;email-UUID&gt;", "&lt;profile-UUID&gt;"],
    "signing_key": "&lt;UUID des Signatur-Zertifikats&gt;",
    "sub_mode": "hashed_user_id",
    "include_claims_in_id_token": true,
    "grant_types": ["authorization_code", "refresh_token"],
    "redirect_uris": [{
      "matching_mode": "strict",
      "url": "https://blog.example.com/wp-admin/admin-ajax.php?action=openid-connect-authorize"
    }]
  }'</code></pre>



<h2 class="wp-block-heading">2. Authentik: Anwendung anlegen</h2>



<p class="wp-block-paragraph">Unter <strong>Applications → Applications → Create</strong>:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Einstellung</th><th>Wert</th></tr></thead><tbody><tr><td>Name</td><td>Blog</td></tr><tr><td>Slug</td><td><code>blog</code> (wird Teil der Issuer- und JWKS-Adresse)</td></tr><tr><td>Provider</td><td>Provider for Blog</td></tr><tr><td>Launch URL</td><td><code>https://blog.example.com/wp-admin/</code></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Danach die Anwendung öffnen, unter <strong>Policy / Group / User Bindings</strong> die Benutzer oder die Gruppe binden, die sich bei WordPress anmelden dürfen. Ohne Binding kann jeder Authentik-Benutzer die Anwendung nutzen.</p>



<p class="wp-block-paragraph">Alle Endpunkte stehen im Discovery-Dokument der Anwendung. Ruf es einmal auf, um zu prüfen, dass alles passt:</p>



<pre class="wp-block-code"><code>curl -s https://auth.example.com/application/o/blog/.well-known/openid-configuration</code></pre>



<h2 class="wp-block-heading">3. WordPress: Plugin installieren</h2>



<p class="wp-block-paragraph">Installiere und aktiviere <strong>OpenID Connect Generic</strong> (von daggerhart) aus dem Plugin-Verzeichnis. Die Einstellungen findest du unter <strong>Einstellungen → OpenID Connect Client</strong>. Sobald das Plugin aktiv ist, zeigt die Anmeldeseite einen Button. Er funktioniert erst, wenn die Einstellungen unten gespeichert sind.</p>



<h2 class="wp-block-heading">4. WordPress: alle Plugin-Einstellungen</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Feld</th><th>Wert</th></tr></thead><tbody><tr><td>Login Type</td><td>OpenID Connect button on login form</td></tr><tr><td>Client ID</td><td>aus dem Authentik-Provider</td></tr><tr><td>Client Secret Key</td><td>aus dem Authentik-Provider</td></tr><tr><td>OpenID Scope</td><td><code>openid email profile</code></td></tr><tr><td>Login Endpoint URL</td><td><code>https://auth.example.com/application/o/authorize/</code></td></tr><tr><td>Userinfo Endpoint URL</td><td><code>https://auth.example.com/application/o/userinfo/</code></td></tr><tr><td>Token Validation Endpoint URL</td><td><code>https://auth.example.com/application/o/token/</code></td></tr><tr><td>End Session Endpoint URL</td><td><code>https://auth.example.com/application/o/blog/end-session/</code></td></tr><tr><td>JWKS URI</td><td><code>https://auth.example.com/application/o/blog/jwks/</code></td></tr><tr><td>Issuer (falls deine Version danach fragt)</td><td><code>https://auth.example.com/application/o/blog/</code></td></tr><tr><td>ACR values</td><td>leer lassen</td></tr><tr><td>Identity Key</td><td><code>preferred_username</code></td></tr><tr><td>Disable SSL Verify</td><td>aus</td></tr><tr><td>HTTP Request Timeout</td><td>5</td></tr><tr><td>Nickname Key</td><td><code>preferred_username</code></td></tr><tr><td>Email Formatting</td><td><code>{email}</code></td></tr><tr><td>Display Name Formatting</td><td><code>{name}</code></td></tr><tr><td>Identify with User Name</td><td>aus</td></tr><tr><td>Enable Refresh Token</td><td>an</td></tr><tr><td>Link Existing Users</td><td>an</td></tr><tr><td>Create user if does not exist</td><td>aus</td></tr><tr><td>Redirect Back to Origin Page</td><td>an</td></tr><tr><td>Redirect to the login screen when session is expired</td><td>aus</td></tr><tr><td>Enable Logging</td><td>beim Testen an, danach aus</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Nur die JWKS-URI, die End-Session-URL und der Issuer enthalten den Slug der Anwendung. Die Endpunkte für authorize, token und userinfo sind in Authentik für alle Anwendungen gleich.</p>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tipp:</strong> Ganz unten auf der Einstellungsseite des Plugins steht die genaue Redirect-URI, die WordPress verwendet. Kopier sie von dort nach Authentik, damit beide Zeichen für Zeichen übereinstimmen.</p>
</div>



<h3 class="wp-block-heading">Wie bestehende Benutzer zugeordnet werden</h3>



<p class="wp-block-paragraph">Mit <em>Link Existing Users</em> sucht das Plugin einen WordPress-Benutzer, dessen Benutzername dem Wert des Identity Keys entspricht, hier also dem Authentik-Benutzernamen (<code>preferred_username</code>). Bei der ersten Anmeldung werden die beiden Konten verknüpft, danach bleibt die Verknüpfung fest. Verwende in WordPress und Authentik denselben Benutzernamen. Solange <em>Create user if does not exist</em> aus ist, bekommt niemand ein neues WordPress-Konto, nur weil er einen Authentik-Zugang hat.</p>



<h3 class="wp-block-heading">Optional: das Secret aus der Datenbank heraushalten</h3>



<p class="wp-block-paragraph">Statt die Werte auf der Einstellungsseite einzutragen, kannst du sie in der <code>wp-config.php</code> definieren. Das Plugin verwendet dann diese Werte und zeigt die Felder schreibgeschützt an:</p>



<pre class="wp-block-code"><code>define( 'OIDC_CLIENT_ID', 'deine-client-id' );
define( 'OIDC_CLIENT_SECRET', 'dein-client-secret' );
define( 'OIDC_CLIENT_SCOPE', 'openid email profile' );
define( 'OIDC_ENDPOINT_LOGIN_URL', 'https://auth.example.com/application/o/authorize/' );
define( 'OIDC_ENDPOINT_USERINFO_URL', 'https://auth.example.com/application/o/userinfo/' );
define( 'OIDC_ENDPOINT_TOKEN_URL', 'https://auth.example.com/application/o/token/' );
define( 'OIDC_ENDPOINT_LOGOUT_URL', 'https://auth.example.com/application/o/blog/end-session/' );</code></pre>



<h2 class="wp-block-heading">5. Testen</h2>



<p class="wp-block-paragraph">Bevor du auf den Button klickst, teste die Anfrage in der Kommandozeile. Ein funktionierender Provider leitet zum Authentik-Login weiter:</p>



<pre class="wp-block-code"><code>curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}n" 
  "https://auth.example.com/application/o/authorize/?response_type=code&amp;scope=openid%20email%20profile&amp;client_id=DEINE_CLIENT_ID&amp;state=test&amp;redirect_uri=https%3A%2F%2Fblog.example.com%2Fwp-admin%2Fadmin-ajax.php%3Faction%3Dopenid-connect-authorize"

# gut:    302 -> https://auth.example.com/if/flow/default-authentication-flow/?...
# falsch: 302 -> https://blog.example.com/...?error=invalid_request&amp;...</code></pre>



<p class="wp-block-paragraph">Wenn das passt: aus WordPress abmelden, auf den Login-Button klicken und mit Authentik anmelden. Du solltest als dein bestehender Benutzer im Dashboard landen.</p>



<h2 class="wp-block-heading">Fehlersuche</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Was du siehst</th><th>Wahrscheinliche Ursache</th></tr></thead><tbody><tr><td>„The request is otherwise malformed“</td><td>Keine Grant Types im Provider (über die API angelegt)</td></tr><tr><td>„Redirect URI Error“</td><td>Die Redirect-URI in Authentik weicht von der ab, die WordPress sendet, schon ein Schrägstrich reicht</td></tr><tr><td>Zurück in WordPress, aber nicht angemeldet</td><td>Kein WordPress-Benutzer passt zu <code>preferred_username</code>, und das Anlegen neuer Benutzer ist aus</td></tr><tr><td>„Permission denied“ in Authentik</td><td>Dein Benutzer ist nicht an die Anwendung gebunden</td></tr><tr><td>Eine leere Seite mit „0“</td><td>Authentik hat einen Fehler an die Redirect-URI zurückgegeben. Sieh ins Authentik-Serverlog.</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">Was ich gelernt habe</h2>



<ul class="wp-block-list">
<li>Die Authentik-Oberfläche setzt Standardwerte, die die API nicht setzt.</li>



<li>Das Serverlog nennt den wahren Grund, wenn der Browser nur einen allgemeinen Fehler zeigt.</li>



<li>Behalte die lokale WordPress-Anmeldung als Rückfallebene. Wenn der Identity Provider ausfällt, willst du trotzdem auf deine eigene Seite kommen.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
