<?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/feed/" rel="self" type="application/rss+xml" />
	<link>https://selfhostjournal.eu</link>
	<description>Real setups, real fixes</description>
	<lastBuildDate>Fri, 23 Oct 2026 19:18:45 +0000</lastBuildDate>
	<language>en-US</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 locked out my own AI assistant: allowing a published IP range</title>
		<link>https://selfhostjournal.eu/traefik-geoblock-allowed-ip-range/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 19:18:45 +0000</pubDate>
				<category><![CDATA[Security and SSO]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=165</guid>

					<description><![CDATA[After switching on GeoBlock, Claude's connector to Home Assistant stopped working while everything else was fine. The logs showed why, and one allowedIPAddresses entry fixed it.]]></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">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> After I switched on Traefik GeoBlock, Claude&#8217;s connector to Home Assistant stopped working, while Home Assistant itself was fine for everyone at home.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> The connector calls from Anthropic&#8217;s servers in the US, and GeoBlock only allowed my own countries. One <code>allowedIPAddresses</code> entry with the provider&#8217;s published IP range fixed it.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Traefik 3, GeoBlock plugin (PascalMinder) 0.3.8, Home Assistant with an MCP server for Claude</p>
</div>



<p class="wp-block-paragraph">In <a href="/traefik-hardening-geoblock-crowdsec-sso/">Hardening Traefik</a> I put GeoBlock in front of every service: only visitors from the countries my family lives in get through, everyone else gets a 403. It worked exactly as planned. A bit too well, as it turned out: it also locked out a visitor I wanted, my own AI assistant.</p>



<p class="wp-block-paragraph">This is a short story about a security measure that does its job, breaks something you need, and how to find out which one it was.</p>



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



<p class="wp-block-paragraph">I use Claude with a connector to Home Assistant, so I can ask about sensors, automations and logs in plain language. Shortly after the hardening, the connector couldn&#8217;t reach Home Assistant anymore. Every attempt failed.</p>



<p class="wp-block-paragraph">At the same time, Home Assistant was perfectly fine: the dashboard loaded at home, the app on my phone worked, automations ran. Only this one client was affected.</p>



<h2 class="wp-block-heading">2. What I checked first (and why it misled me)</h2>



<p class="wp-block-paragraph">A connector that signs in with OAuth has plenty of parts that can break, so that&#8217;s where you look first:</p>



<ul class="wp-block-list">
<li><strong>Sign-in and tokens:</strong> an expired token or a broken OAuth setup would explain a failing client. But reconnecting doesn&#8217;t help when the requests are blocked before they arrive.</li>



<li><strong>Home Assistant logs:</strong> nothing. No failed login, no error from the MCP integration, not even a request.</li>



<li><strong>The URL:</strong> it opens fine in the browser.</li>
</ul>



<p class="wp-block-paragraph">The empty Home Assistant log was the real clue, and it&#8217;s easy to read it the wrong way. &#8220;No errors&#8221; sounded like &#8220;nothing wrong in Home Assistant&#8221;. It actually meant: the requests never arrived. Something in front of Home Assistant was answering them. And &#8220;it works in my browser&#8221; proved nothing, because my browser comes from an allowed country.</p>



<h2 class="wp-block-heading">3. The real cause: Traefik GeoBlock did its job</h2>



<p class="wp-block-paragraph">Claude&#8217;s connectors don&#8217;t run in my browser or on my phone. They call my server from Anthropic&#8217;s infrastructure, which sits in the US. For GeoBlock that&#8217;s just another request from a country that isn&#8217;t on the list.</p>



<p class="wp-block-paragraph">Two logs show it. GeoBlock writes every denied request to its own file. A line looks like this (example address):</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">And the Traefik access log shows the 403 with an empty backend (<code>"-"</code> after the router name). That dash means the request never reached the service (again an example line):</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">The log is full of scanners from all over the world, so the connector&#8217;s requests don&#8217;t stand out by themselves. Compare the denied addresses with the range Anthropic publishes for its outgoing traffic, and they do.</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>Tip:</strong> when one client fails and the app&#8217;s own log is empty, look at the reverse proxy first. A 403 with no backend in the access log is the typical sign of a middleware saying no.</p>
</div>



<h2 class="wp-block-heading">4. The fix: allow the published IP range</h2>



<p class="wp-block-paragraph">GeoBlock has an option for exactly this: <code>allowedIPAddresses</code>. Addresses in this list are let through no matter which country they come from. One more label on the 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">Then recreate the container so Traefik reads the new labels:</p>



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



<p class="wp-block-paragraph">The range above is the one Anthropic lists at the time of writing. Check <a href="https://platform.claude.com/docs/en/api/ip-addresses" target="_blank" rel="noreferrer noopener">Anthropic&#8217;s IP address page</a> for the current one before you copy it. The same approach works for any other service that has to reach you from abroad, as long as its provider publishes its IP ranges.</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>Warning:</strong> only allow ranges the provider publishes officially. Never add an IP you found in your own log just because it looks right, and never open a whole country for one service. Also keep in mind what an exception means: this range is shared by all of the provider&#8217;s customers, not just you. GeoBlock lets them through, so the login of the service behind it still has to hold. In my case Home Assistant&#8217;s own sign-in and CrowdSec are still in place.</p>
</div>



<h2 class="wp-block-heading">5. How to verify</h2>



<ol class="wp-block-list">
<li>Check that Traefik picked up the label:<br><code>docker inspect traefik --format '{{ index .Config.Labels "traefik.http.middlewares.geoblock.plugin.geoblock.allowedIPAddresses[0]" }}'</code></li>



<li>Use the connector once (in my case: ask Claude for a sensor value).</li>



<li>Make sure the range no longer shows up as denied:<br><code>grep -E '\[160\.79\.1(0[4-9]|1[01])\.' traefik-logs/geoblock.log | tail</code><br>No new lines after the restart means GeoBlock lets the connector through.</li>
</ol>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li>&#8220;Works in my browser&#8221; is no test for a geo-block. Ask where the failing client really calls from.</li>



<li>An empty application log is a finding, not a dead end: the request was stopped earlier.</li>



<li>Before switching on an allow list, write down every service that calls in from outside: AI connectors, webhooks, monitoring, push services. Each one needs a deliberate exception or a different path.</li>
</ul>



<p class="wp-block-paragraph">The full GeoBlock and CrowdSec setup is in <a href="/traefik-hardening-geoblock-crowdsec-sso/">Hardening Traefik: geo-blocking, CrowdSec and single sign-on</a>.</p>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Authentik: &#8220;The request is otherwise malformed&#8221; after creating a provider through the API</title>
		<link>https://selfhostjournal.eu/authentik-request-otherwise-malformed/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 19:07:29 +0000</pubDate>
				<category><![CDATA[Security and SSO]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=160</guid>

					<description><![CDATA[Every login through a new Authentik provider failed with invalid_request. The cause was one missing field in the API call, and the browser error points the wrong way.]]></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">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Every login through a new Authentik OAuth2/OpenID provider fails with <code>invalid_request</code>: &#8220;The request is otherwise malformed&#8221;.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> The provider was created through the API, and its <code>grant_types</code> list was empty. Set it to <code>authorization_code</code> and <code>refresh_token</code>, then test the authorize URL with curl.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Authentik 2026.8, WordPress with the OpenID Connect Generic plugin 3.11</p>
</div>



<p class="wp-block-paragraph">I add most apps to Authentik through the admin interface. For this blog I wanted to script it, so I created the OAuth2 provider with a single API call. The call succeeded, the application showed up, the WordPress plugin was configured with the right client ID, secret and endpoints. And then not a single login worked.</p>



<p class="wp-block-paragraph">The fix turned out to be one field. Finding it took longer than it should have, because the error message points in the wrong direction.</p>



<h2 class="wp-block-heading">1. The symptom: &#8220;request is otherwise malformed&#8221;</h2>



<p class="wp-block-paragraph">Clicking &#8220;Login with OpenID Connect&#8221; in WordPress sent me to Authentik, and Authentik sent me straight back with an error page instead of a login form:</p>



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



<p class="wp-block-paragraph">No username prompt, no password prompt. Authentik refused the request before the login flow even started.</p>



<h2 class="wp-block-heading">2. What I checked first (and why it misled me)</h2>



<p class="wp-block-paragraph">&#8220;Malformed request&#8221; sounds like the client sent something wrong. So I went through the usual suspects on the WordPress side:</p>



<ul class="wp-block-list">
<li><strong>Redirect URI:</strong> character by character against the provider. Authentik uses strict matching, so a missing slash or a different query string is enough to break it. It matched.</li>



<li><strong>Client ID:</strong> copied again, no stray spaces.</li>



<li><strong>Scopes:</strong> <code>openid email profile</code>, all three available in the provider.</li>



<li><strong>Endpoints:</strong> taken from the provider&#8217;s <code>.well-known/openid-configuration</code>.</li>
</ul>



<p class="wp-block-paragraph">Everything was correct. The request from WordPress was fine. The problem was on the Authentik side, and the browser error doesn&#8217;t tell you that.</p>



<h2 class="wp-block-heading">3. The real cause: an empty grant_types list</h2>



<p class="wp-block-paragraph">The Authentik server log had the more useful message. With 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">It said &#8220;Invalid grant_type for provider&#8221;. Every OAuth2 provider in Authentik has a list of allowed grant types. When you create a provider in the admin interface, the form fills in sensible defaults. The API doesn&#8217;t: <code>grant_types</code> is an optional field, and if you leave it out, the provider is saved with an <strong>empty list</strong>. An empty list means no grant type is allowed, so not even the normal authorization code flow works.</p>



<p class="wp-block-paragraph">My API call looked roughly like this (shortened):</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">Every required field is there, so the API accepts it without a warning. The one field that decides whether anyone can log in is the one that&#8217;s missing.</p>



<h2 class="wp-block-heading">4. The fix</h2>



<p class="wp-block-paragraph">Set the grant types on the existing provider. A login flow for a website needs <code>authorization_code</code>, and <code>refresh_token</code> if the app renews its tokens:</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">You can also open the provider in the admin interface and save it there with both grant types ticked. For new providers, add the line straight to the create call:</p>



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



<p class="wp-block-paragraph">While you&#8217;re at it, compare the token lifetimes too (<code>access_token_validity</code>, <code>refresh_token_threshold</code>) with a provider you created in the interface. API-created providers don&#8217;t always get the same values, and it&#8217;s easier to keep all providers alike.</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>Warning:</strong> only allow the grant types the app really uses. <code>password</code> and <code>client_credentials</code> skip the login flow, and with it your MFA and policies. A website login doesn&#8217;t need them.</p>
</div>



<h2 class="wp-block-heading">5. How to verify (without a browser)</h2>



<p class="wp-block-paragraph">You don&#8217;t need to click through the login to test a provider. Call the authorize endpoint with curl and look at where Authentik sends you:</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>Working:</strong> a <code>302</code> to Authentik&#8217;s own login flow, for example <code>/if/flow/default-authentication-flow/…</code>.</li>



<li><strong>Broken:</strong> a <code>302</code> back to your app&#8217;s redirect URI with <code>error=…</code> in the URL. The error name tells you what Authentik didn&#8217;t like.</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>Tip:</strong> the redirect URI in the curl call must be URL-encoded and match the provider exactly, otherwise you test the wrong problem. I now run this check after every provider I create, whether through the API or the interface. It takes five seconds.</p>
</div>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li>&#8220;Malformed request&#8221; in the browser doesn&#8217;t mean the client is wrong. Read the identity provider&#8217;s server log before you change anything in the app.</li>



<li>The admin interface and the API don&#8217;t create the same object. The interface fills in defaults, the API stores exactly what you send.</li>



<li>A curl test of the authorize URL checks a provider in seconds, without logging in.</li>
</ul>



<p class="wp-block-paragraph">How the blog login is set up end to end is in <a href="/wordpress-sso-authentik/">Single sign-on for WordPress with Authentik</a>. All provider fields are described in the <a href="https://docs.goauthentik.io/" target="_blank" rel="noreferrer noopener">official Authentik documentation</a>.</p>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Nextcloud in Docker behind Traefik: my configuration explained</title>
		<link>https://selfhostjournal.eu/nextcloud-docker-traefik-configuration/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 18:41:33 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=154</guid>

					<description><![CDATA[My Nextcloud setup in Docker: PostgreSQL, Redis and cron, the reverse proxy settings that matter behind Traefik, single sign-on with Authentik, Nextcloud Office with the built-in Collabora server, and nightly 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">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> A Nextcloud container starts in a minute. Making it fast, reachable over HTTPS, free of admin warnings and safe to back up takes a lot more than <code>docker run nextcloud</code>.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> One compose project with Nextcloud, PostgreSQL, Redis and a cron container, published through Traefik, logins through Authentik, documents in the browser with the built-in Collabora server, and a nightly backup in maintenance mode.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Nextcloud 35 (official <code>nextcloud:35</code> image, PHP 8.5), PostgreSQL 16, Redis, Traefik 3.7, Authentik, Docker Compose on a small home server</p>
</div>



<p class="wp-block-paragraph">Nextcloud is the service in my homelab that the whole family actually uses: files, calendars, contacts, notes and, since recently, Office documents. That makes it the one I least want to break. This article walks through my configuration: the containers, the settings that matter behind a reverse proxy, single sign-on, Nextcloud Office, the maintenance after updates, and how I back it up. It builds on my <a href="/traefik-reverse-proxy-docker/">Traefik article</a>; if you don&#8217;t have a reverse proxy yet, start there.</p>



<h2 class="wp-block-heading">1. The stack at a glance</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Container</th><th>Image</th><th>Job</th></tr></thead><tbody><tr><td><code>nextcloud</code></td><td><code>nextcloud:35</code></td><td>The web app (Apache + PHP)</td></tr><tr><td><code>postgres</code></td><td><code>postgres:16</code></td><td>The database. Much happier than SQLite once more than one person syncs</td></tr><tr><td><code>redis</code></td><td><code>redis</code></td><td>File locking and shared cache</td></tr><tr><td><code>nextcloud-cron</code></td><td><code>nextcloud:35</code></td><td>Runs the background jobs every 5 minutes</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The tag <code>nextcloud:35</code> is a deliberate choice. It follows minor and patch releases of version 35, so my update script (it runs <code>docker compose pull &amp;&amp; docker compose up -d</code> three times a week) picks up security fixes on its own. A new major version never arrives by surprise. Nextcloud can only upgrade one major version at a time, and I want to read the release notes and have a fresh backup before that step.</p>



<h2 class="wp-block-heading">2. The .env file</h2>



<p class="wp-block-paragraph">Everything that differs between installations, and every password, lives in a <code>.env</code> file next to the compose file. Make it readable only for its owner: <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=change-me-long-random

NEXTCLOUD_REDIS_PASSWORD=change-me-too

# only used on the very first start
NEXTCLOUD_ADMIN_USERNAME=admin
NEXTCLOUD_ADMIN_PASSWORD=change-me-as-well</code></pre>



<p class="wp-block-paragraph">A good way to create the passwords: <code>openssl rand -base64 32</code>. The admin user and password are only read when Nextcloud installs itself on the first start. Changing them later in <code>.env</code> does nothing.</p>



<h2 class="wp-block-heading">3. Database, Redis and 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">A few details that are easy to miss:</p>



<ul class="wp-block-list">
<li><strong>The database and Redis are only in <code>nextcloud-network</code>.</strong> They have no published ports and Traefik can&#8217;t see them. Only Nextcloud talks to them.</li>



<li><strong>Health checks matter here.</strong> Nextcloud waits for <code>service_healthy</code> (see below), so it never starts its install or upgrade step against a database that is still booting.</li>



<li><strong>The cron container</strong> uses the same image and the same volumes as Nextcloud, but runs <code>/cron.sh</code>, which calls <code>cron.php</code> every 5 minutes. After the first start, switch Nextcloud from AJAX to cron: <code>occ background:cron</code> (or Administration settings → Basic settings).</li>
</ul>



<h2 class="wp-block-heading">4. The 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}
      # behind the reverse proxy
      OVERWRITEHOST: ${NEXTCLOUD_HOSTNAME}
      OVERWRITEPROTOCOL: https
      OVERWRITECLIURL: ${NEXTCLOUD_URL}
      TRUSTED_PROXIES: 172.16.0.0/12
      # big uploads and 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>Two storage locations.</strong> The named volume <code>nextcloud-app</code> holds the application itself, including <code>config/config.php</code> and the apps you install from the app store (<code>custom_apps</code>). The user files go to a bind mount on a big disk (<code>/srv/nextcloud/data</code> here). Keeping them apart lets me put the data on a separate SSD and back the two up differently. Just don&#8217;t forget the volume: <code>config.php</code> contains the instance&#8217;s secret and password salt, and without it a restore of the data alone is much harder.</p>



<p class="wp-block-paragraph"><strong>The reverse proxy settings.</strong> Nextcloud only sees plain HTTP from Traefik on port 80. Without these four variables it builds wrong links, refuses logins from the &#8220;untrusted&#8221; host name or shows every visitor with Traefik&#8217;s IP address:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Variable</th><th>Why</th></tr></thead><tbody><tr><td><code>OVERWRITEHOST</code></td><td>The host name Nextcloud uses in links and redirects</td></tr><tr><td><code>OVERWRITEPROTOCOL=https</code></td><td>Links start with https even though the request inside Docker is http. Without it, login redirects loop or break</td></tr><tr><td><code>OVERWRITECLIURL</code></td><td>Correct links in e-mails and in anything that background jobs or <code>occ</code> generate</td></tr><tr><td><code>TRUSTED_PROXIES</code></td><td>Nextcloud only believes the <code>X-Forwarded-For</code> header from these addresses. Then the log, the brute-force protection and the &#8220;last login&#8221; show the real client IP</td></tr></tbody></table></figure>



<p class="wp-block-paragraph"><code>172.16.0.0/12</code> covers Docker&#8217;s default network ranges, where Traefik lives. Put in what <code>docker network inspect proxy</code> shows on your host, and don&#8217;t add more than that: every range you trust can fake client IPs.</p>



<h2 class="wp-block-heading">5. Publishing it through 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 discovery for phones and calendar 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/"

      # all other .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">The two redirects fix the most common warning in the admin overview (&#8220;Your web server is not properly set up to resolve /.well-known/caldav&#8221;). They also let phones find calendars and contacts when you only type the host name. The <code>$$</code> is needed because Compose would otherwise read <code>${1}</code> as a variable.</p>



<p class="wp-block-paragraph">HSTS (the <code>stsSeconds</code> line) tells browsers to use only HTTPS for this host for the next 180 days, which removes another admin warning. In my setup, a general security header middleware on the <code>websecure</code> entrypoint sets HSTS for every service anyway (see the <a href="/traefik-hardening-geoblock-crowdsec-sso/">hardening article</a>). I keep the line here so the Nextcloud stack still gets it if I ever change that.</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>One middlewares line per router:</strong> if a compose file sets <code>traefik.http.routers.nextcloud.middlewares</code> twice, there is no error. The second line silently replaces the first. My own file had exactly that for a long time: an earlier line with a buffering middleware was never active, and I only noticed while writing this article (<code>docker inspect</code> shows the labels the container really has). Put all middlewares into one comma-separated line.</p>
</div>



<p class="wp-block-paragraph">You don&#8217;t need a buffering middleware for big uploads, by the way. Traefik has no request size limit by default, and the Nextcloud clients upload large files in chunks anyway.</p>



<h2 class="wp-block-heading">6. Logging in with Authentik</h2>



<p class="wp-block-paragraph">Like most of my services, Nextcloud uses single sign-on with Authentik. I use the official app <strong>OpenID Connect user backend</strong> (<code>user_oidc</code>). The setup on the Authentik side (an OAuth2/OpenID provider plus an application) is the same as in my <a href="/wordpress-sso-authentik/">WordPress SSO article</a>. The redirect URI for Nextcloud is <code>https://cloud.example.com/apps/user_oidc/code</code>.</p>



<p class="wp-block-paragraph">Then, in the 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 from Authentik&gt;" \
  --clientsecret="&lt;client secret from 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">The last two options are the important ones. <code>--mapping-uid=preferred_username</code> with <code>--unique-uid=0</code> makes the Nextcloud user name equal to the Authentik user name, instead of a long hash. So an Authentik user <code>anna</code> lands in the existing Nextcloud account <code>anna</code> with all her files, and the paths stay readable.</p>



<p class="wp-block-paragraph">The login page now shows a &#8220;Log in with Authentik&#8221; button. The normal Nextcloud login stays available below it. I keep it on purpose: if Authentik is down, the local admin account still works. Desktop and mobile clients log in through the browser, so they use SSO too.</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>Tip:</strong> don&#8217;t put Authentik&#8217;s forward-auth middleware in front of Nextcloud. It breaks the sync clients and WebDAV, which can&#8217;t do a browser login on every request. Use the OIDC app instead.</p>
</div>



<h2 class="wp-block-heading">7. Nextcloud Office without an extra container</h2>



<p class="wp-block-paragraph">For opening .docx, .xlsx and .odt files in the browser I use Nextcloud Office with the <strong>built-in CODE server</strong> (Collabora Online Development Edition). It runs inside the Nextcloud container: no extra container, no extra host name, no extra certificate. For a household with a handful of users that&#8217;s plenty.</p>



<pre class="wp-block-code"><code>occ app:install richdocumentscode   # the built-in Collabora server (on ARM: richdocumentscode_arm64)
occ app:install richdocuments       # the Nextcloud Office app
occ richdocuments:activate-config</code></pre>



<p class="wp-block-paragraph">After that, Administration settings → Office should say that the built-in server is used. The WOPI URL points to <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>Watch your security headers:</strong> the editor runs in an iframe inside Nextcloud. A global <code>X-Frame-Options: DENY</code> or <code>SAMEORIGIN</code> header from the reverse proxy breaks it with an empty, grey document. Nextcloud sets its own frame headers, so leave that header to the app.</p>
</div>



<p class="wp-block-paragraph">Also check that the Office app version fits your Nextcloud major version. The app store sometimes keeps an older, incompatible version installed after an upgrade; <code>occ app:update --all</code> fixes that.</p>



<h2 class="wp-block-heading">8. Clean admin overview after an update</h2>



<p class="wp-block-paragraph">After a major upgrade, Administration settings → Overview usually shows a few new warnings. These are the commands I needed, roughly in this order (with a fresh backup):</p>



<pre class="wp-block-code"><code># new database indices that came with the upgrade (takes seconds to minutes)
occ db:add-missing-indices

# only if the overview suggests it; can take a while on big instances
occ maintenance:repair --include-expensive

# phone numbers in profiles without a country code
occ config:system:set default_phone_region --value="DE"

# run heavy background jobs at night (hour in UTC, 1 = 01:00-05:00)
occ config:system:set maintenance_window_start --type=integer --value=1

# the same checks as the admin overview, on the command line
occ setupchecks</code></pre>



<p class="wp-block-paragraph">Memory caching is configured automatically by the official image: APCu for the local cache and Redis for file locking, as soon as <code>REDIS_HOST</code> is set. You can check it with <code>occ config:system:get memcache.locking</code>.</p>



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



<p class="wp-block-paragraph">Nextcloud is backed up every night by a script on the host, not inside the stack. The order matters:</p>



<ol class="wp-block-list">
<li><code>occ maintenance:mode --on</code>, so no file or database row changes during the copy</li>



<li><code>pg_dump</code> of the database into a plain SQL file (never copy the live database files)</li>



<li><code>rsync</code> of the data folder into a dated snapshot, with a bandwidth limit so the disks stay usable</li>



<li>a copy of <code>config/</code>, <code>custom_apps/</code> and <code>themes/</code> from the app volume, plus the compose file and <code>.env</code></li>



<li><code>occ maintenance:mode --off</code>, in a <code>trap</code>, so it also happens when one step fails</li>
</ol>



<p class="wp-block-paragraph">On a normal night, maintenance mode lasts a few minutes. Unchanged files are hard-linked between snapshots, so 14 days of history cost little more than one copy. How the snapshots, the database dumps and the offline copy work together is in <a href="/homelab-backup-snapshots-dumps-offline-copy/">my backup article</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>Tip:</strong> after a restore, run <code>occ maintenance:data-fingerprint</code>. It tells the sync clients that the server went back in time, so they ask before overwriting newer files instead of silently syncing in either direction.</p>
</div>



<h2 class="wp-block-heading">10. When something doesn&#8217;t work</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Symptom</th><th>Usual cause</th></tr></thead><tbody><tr><td>&#8220;Access through untrusted domain&#8221;</td><td>The host name is missing in <code>trusted_domains</code>. <code>NEXTCLOUD_TRUSTED_DOMAINS</code> only works on the first install; later use <code>occ config:system:set trusted_domains 1 --value=cloud.example.com</code></td></tr><tr><td>Login loops back to the login page, or links start with http://</td><td><code>OVERWRITEPROTOCOL=https</code> missing</td></tr><tr><td>Every log entry shows the same internal IP</td><td><code>TRUSTED_PROXIES</code> doesn&#8217;t contain Traefik&#8217;s network</td></tr><tr><td>&#8220;Your web server is not properly set up to resolve /.well-known/caldav&#8221;</td><td>The redirect middlewares are not active. Check with <code>docker inspect</code> which middlewares the router really got</td></tr><tr><td>Office documents open as an empty grey page</td><td>An <code>X-Frame-Options</code> header from the proxy, or an Office app version that doesn&#8217;t fit the Nextcloud version</td></tr><tr><td>&#8220;Last background job execution ran &#8230; hours ago&#8221;</td><td>The cron container isn&#8217;t running, or Nextcloud is still set to AJAX</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The most useful commands for the rest:</p>



<pre class="wp-block-code"><code># the newest errors from Nextcloud's own log
occ log:tail 20

# what the admin overview would complain about
occ setupchecks

# which labels (and so which Traefik middlewares) the container really has
docker inspect nextcloud-nextcloud-1 --format '{{json .Config.Labels}}'</code></pre>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li><strong>Pin the major version.</strong> <code>nextcloud:35</code> gives me automatic security fixes, and the big upgrade happens when I decide, with a backup.</li>



<li><strong>The reverse proxy settings are not optional.</strong> Most strange Nextcloud problems behind Traefik came down to <code>OVERWRITEPROTOCOL</code> or <code>TRUSTED_PROXIES</code>.</li>



<li><strong>Check what is really running.</strong> A label that is overwritten by a later one raises no error. <code>docker inspect</code> shows the truth, the compose file only shows the intention.</li>



<li><strong>The app volume belongs in the backup.</strong> For a long time I only backed up the data folder and the database. Without <code>config.php</code>, that&#8217;s a much harder restore.</li>



<li><strong>SSO via OIDC, not forward-auth.</strong> The sync clients keep working, and the local login is my emergency exit.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Monitoring my Docker host with Home Assistant: containers, disks, HTTP checks and alerts</title>
		<link>https://selfhostjournal.eu/docker-host-monitoring-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=150</guid>

					<description><![CDATA[Home Assistant watches the Docker host like the rest of the house: Portainer and SSH integrations, a container-down alert, HTTP checks that expect the right answer, disk space, backups and a 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">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> A container that quietly stops, a disk that fills up, a service that answers with the wrong page: on a home server you usually find out when someone in the family says &#8220;it doesn&#8217;t work&#8221;.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> Home Assistant watches the Docker host the same way it watches the rest of the house: one entity per container, host metrics, HTTP checks from the outside, backup status, and a handful of automations that send a Telegram message when something is wrong.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Home Assistant 2026.10, the Portainer integration, my own SSH Linux Management add-on (MQTT), about 25 containers on one Docker host, Telegram and the Companion app for notifications</p>
</div>



<p class="wp-block-paragraph">Home Assistant already knows how to tell me when a window is open or a battery is low. So instead of running a separate monitoring stack for my server, I let Home Assistant watch that too. Everything ends up in the same place: one dashboard, one notification channel, and automations I already know how to write. This article shows the two integrations I use to get the Docker host into Home Assistant, the alerts built on top of them, and what I learned along the way.</p>



<h2 class="wp-block-heading">1. Two integrations, two jobs</h2>



<p class="wp-block-paragraph">Home Assistant runs in a VM, the containers on the host next to it. To see inside the host, I use two integrations, because each one is good at something different:</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>Connects via</td><td>Portainer&#8217;s API with an access token</td><td>One SSH connection to the host</td></tr><tr><td>Per container</td><td>Status, CPU, memory, restart count, out-of-memory, image updates, restart/pause/recreate buttons</td><td>Running on/off as a switch, restart button</td></tr><tr><td>How fast</td><td>Polled at a regular interval</td><td>Pushed from Docker&#8217;s event stream, instantly</td></tr><tr><td>Host</td><td>–</td><td>CPU, RAM, disk use per mount, libvirt VMs</td></tr><tr><td>Extra</td><td>–</td><td>Reads the backup status file of my <a href="/homelab-backup-snapshots-dumps-offline-copy/">backup setup</a></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">For the alerts below I use the Portainer status sensors for containers and the SSH add-on for everything about the host. If you only want one, start with Portainer: it&#8217;s a built-in integration and gives you the most per container.</p>



<h2 class="wp-block-heading">2. The Portainer integration</h2>



<p class="wp-block-paragraph">If Portainer already runs on your Docker host, this is the quickest start:</p>



<ol class="wp-block-list">
<li>In Portainer, open your user profile → <strong>Access tokens</strong> → <strong>Add access token</strong>, and copy the token.</li>



<li>In Home Assistant: <strong>Settings → Devices &amp; services → Add integration → Portainer</strong>, enter Portainer&#8217;s URL and the token.</li>



<li>Home Assistant creates one device per container, with entities like <code>binary_sensor.&lt;container&gt;_status</code>, <code>sensor.&lt;container&gt;_memory_usage</code>, <code>sensor.&lt;container&gt;_restart_count</code> and <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>The token can do what its user can do:</strong> with an admin&#8217;s token, Home Assistant can stop and recreate every container. Create a separate Portainer user for Home Assistant if your edition supports roles, and treat the token like a password: it lives in Home Assistant&#8217;s config, so your Home Assistant backups contain it too.</p>
</div>



<p class="wp-block-paragraph">One thing Portainer doesn&#8217;t do: when you delete a container, its device stays in Home Assistant as &#8220;unavailable&#8221;. Remove it by hand (<strong>Settings → Devices</strong>, the device, <strong>Delete</strong>), or your dashboards and automations slowly fill with dead entities.</p>



<h2 class="wp-block-heading">3. The SSH add-on: host metrics without root</h2>



<p class="wp-block-paragraph">For the host itself (disk, CPU, RAM, VMs) I wrote a small Home Assistant add-on, <a href="https://github.com/apps-linux/ha-ssh-mange-secure" target="_blank" rel="noopener">SSH Linux Management</a>. It opens one outbound SSH connection to the host and publishes what it finds through MQTT discovery, so the entities appear in Home Assistant without any YAML:</p>



<ul class="wp-block-list">
<li><strong>Docker:</strong> the remote Docker socket is forwarded through the SSH connection. No Docker TCP port is opened, and container changes arrive instantly through Docker&#8217;s event stream.</li>



<li><strong>Host metrics:</strong> CPU, memory and disk use per mount point (I watch <code>/</code> and the Nextcloud data disk).</li>



<li><strong>VMs:</strong> libvirt VMs as switches with a reboot button, through <code>virsh</code> over the same connection.</li>



<li><strong>The SSH account is not root:</strong> it&#8217;s a normal user in the <code>docker</code> (and <code>libvirt</code>) group, and the host key is pinned on the first connection.</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>Be honest about the <code>docker</code> group:</strong> anyone who can talk to the Docker socket can start a container that mounts the host&#8217;s root filesystem. Membership in that group is effectively root on that machine. &#8220;Not root&#8221; here means: no root password in Home Assistant, one dedicated key you can revoke, and nothing else that account can log in to. If you want real limits, put a Docker socket proxy in front that only allows the API calls you need.</p>
</div>



<p class="wp-block-paragraph">A lesson from running it: the first versions named each Docker entity after the container&#8217;s ID. Every <code>docker compose up</code> after an update creates a new container with a new ID, so Home Assistant got a new entity each time, and after a few weeks I had <code>switch.…_traefik</code>, <code>…_traefik_2</code> up to <code>…_traefik_5</code>. Version 0.11.0 keys the entities on the container <em>name</em> and removes discovery entries for containers that are gone. If you write your own MQTT discovery: choose the <code>unique_id</code> from something that survives a recreate.</p>



<h2 class="wp-block-heading">4. Alert: a container stopped</h2>



<p class="wp-block-paragraph">The most important automation is also the simplest: if any container&#8217;s status sensor is off for two minutes, send a message that names it. The two minutes keep normal restarts (updates, a recreate) from sending false alarms:</p>



<pre class="wp-block-code"><code>alias: System - Docker - Container Down Alert
description: A watched container is not running for 2 minutes → 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
      # … one line per container you care about
    to: "off"
    for:
      minutes: 2
actions:
  - action: telegram_bot.send_message
    data:
      title: "🔴 Docker container down"
      message: "{{ trigger.to_state.attributes.friendly_name }} has not been running for 2 minutes."
  - action: notify.mobile_app_your_phone
    data:
      title: "🔴 Docker container down"
      message: "{{ trigger.to_state.attributes.friendly_name }} has not been running for 2 minutes."</code></pre>



<ul class="wp-block-list">
<li><strong><code>mode: parallel</code>:</strong> if Docker itself restarts, several containers stop at once. Each one should get its own message, not just the first.</li>



<li><strong><code>to: "off"</code>, not &#8220;not on&#8221;:</strong> a sensor that turns <code>unavailable</code> (Portainer unreachable, container deleted) doesn&#8217;t fire. That&#8217;s on purpose: it prevents a flood of alerts when Portainer restarts, but it also means a deleted container is silently dropped from monitoring.</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>Tip:</strong> the list of sensors is the weak spot. When you add, rename or remove a container, the list has to change with it, or the new container is never watched and the old one stays as a dead entry. I review it together with the backup job of every new container. A label on the status sensors and a label-based trigger would remove that step, at the cost of setting the label on each new sensor.</p>
</div>



<h2 class="wp-block-heading">5. Running isn&#8217;t the same as working: HTTP checks</h2>



<p class="wp-block-paragraph">A container can be &#8220;running&#8221; while the service inside answers with an error page, or while the reverse proxy in front of it sends traffic nowhere. So every ten minutes Home Assistant also calls the public addresses, the way a visitor would, and checks the HTTP status code. The calls are <code>shell_command</code> entries in <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">The automation runs each command, reads the status code from the response, and compares it with what that service should answer:</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: "🔴 Traefik dashboard check failed"
          message: "Expected a login (302/401), got {{ traefik.stdout }}."</code></pre>



<ul class="wp-block-list">
<li><strong>Check for the <em>right</em> answer, not just any answer.</strong> My Traefik dashboard and wiki sit behind single sign-on, so they must answer with a redirect to the login (302) or 401. A <code>200</code> would mean they&#8217;re suddenly open to everyone. The check catches a broken login, not just a broken service.</li>



<li><strong>Plan for maintenance.</strong> My nightly Nextcloud backup puts it into maintenance mode, which answers with 503. Between 02:55 and 04:30 the automation ignores exactly that code for Nextcloud, and only that one.</li>
</ul>



<h2 class="wp-block-heading">6. Disk space and the host</h2>



<p class="wp-block-paragraph">A full disk is the classic reason for a whole server to fail at once: databases stop writing, containers crash, logs can&#8217;t be written to explain why. The SSH add-on&#8217;s disk sensors make this a two-line trigger:</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 }} is at {{ trigger.to_state.state }} % used."</code></pre>



<p class="wp-block-paragraph">85 % gives me days, not minutes, to react. The same sensors go on the dashboard as gauges, next to CPU and memory.</p>



<h2 class="wp-block-heading">7. Backups and updates</h2>



<ul class="wp-block-list">
<li><strong>Backups:</strong> a script on the host writes the state of every backup job into a JSON file every five minutes. The SSH add-on reads that file and creates a sensor per job plus one &#8220;backup problem&#8221; binary sensor. If that is on for ten minutes (a job failed, a snapshot is older than 26 hours, the backup disk isn&#8217;t mounted), I get a message with the reason. The details are in my <a href="/homelab-backup-snapshots-dumps-offline-copy/">backup article</a>.</li>



<li><strong>Updates:</strong> an automation runs my Docker update script over SSH three times a week at 04:00 (<code>docker compose pull</code> and <code>up -d</code> per stack). The container alert above is the safety net: if an update breaks a container, I know before breakfast. For the reverse proxy, a pinned major version keeps that risk small (see my <a href="/traefik-reverse-proxy-docker/">Traefik article</a>).</li>



<li><strong>Version changes:</strong> for Zigbee2MQTT I also store the last known version in an <code>input_text</code> helper and send a message when it changes, with a reminder to read the release notes. I added it after an update changed how some devices behaved and I only noticed days later.</li>
</ul>



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



<p class="wp-block-paragraph">A &#8220;Server&#8221; view on my main dashboard shows the host gauges (CPU, RAM, both disks), the backup status, and one tile per container. Each tile shows whether the container runs, and tapping its icon restarts it:</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">It&#8217;s not meant for daily use. It&#8217;s the page I open when a message arrives, to see at a glance whether one container is down or the whole host has a problem, and to restart the obvious candidate from my phone.</p>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li><strong>One place for alerts.</strong> I read Telegram anyway. A separate monitoring system with its own app would be one more thing I ignore.</li>



<li><strong>Check from the outside, too.</strong> &#8220;Container running&#8221; and &#8220;service works for a visitor&#8221; are two different questions. A container can run perfectly while its login is broken or the proxy points elsewhere.</li>



<li><strong>Check the expected answer.</strong> A login page that suddenly answers 200 is a security problem, not a success.</li>



<li><strong>Give every alert a short delay.</strong> Two minutes for containers, ten for backups: real problems stay, restarts don&#8217;t wake you up.</li>



<li><strong>Clean up after removing a container.</strong> Delete its device, take it out of the alert list and the dashboard. Dead entities make a dashboard you stop trusting.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>How I back up my homelab: snapshots, database dumps and an offline copy</title>
		<link>https://selfhostjournal.eu/homelab-backup-snapshots-dumps-offline-copy/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate></pubDate>
				<category><![CDATA[Homelab]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=142</guid>

					<description><![CDATA[Nightly rsync snapshots of every service, databases as checked dumps, a third copy that a laptop pulls onto an encrypted SSD, alerts in Home Assistant and a tested restore script.]]></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">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> A homelab collects things you can&#8217;t download again: documents, passwords, photos, years of sensor history, the pairing of every Zigbee device. One dead disk, one bad update or one ransomware hit, and it&#8217;s gone.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> Nightly snapshots of every service onto a separate disk (databases as dumps, files with rsync hardlinks), a third copy that a laptop pulls onto an encrypted SSD, monitoring that shouts when something is missing, and a restore script that is tested.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> about ten services in Docker (Nextcloud, Vaultwarden, Paperless-ngx, Authentik, BookStack, Zigbee2MQTT, Traefik, PostgreSQL for Home Assistant), two WordPress sites on a web host, bash + rsync + systemd timers, Home Assistant for alerts</p>
</div>



<p class="wp-block-paragraph">Backups are the least exciting part of a homelab, until the day you need one. I built mine in steps, mostly after &#8220;what if…&#8221; moments, and it ended up as plain bash scripts, rsync and systemd timers: no backup software with its own database that I&#8217;d have to restore first. This article walks through the whole setup: what I back up, how the snapshots work, why databases are dumped instead of copied, how the third copy is protected against a hacked server, how I know every night that it worked, and how a restore looks.</p>



<h2 class="wp-block-heading">1. Three copies, two disks, one of them out of reach</h2>



<p class="wp-block-paragraph">The classic rule is 3-2-1: three copies of your data, on two different media, one of them offsite. Here is my version of it, and where it falls short:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Copy</th><th>Where</th><th>Protects against</th></tr></thead><tbody><tr><td>1. Live data</td><td>The server&#8217;s SSD</td><td>nothing – this is what we protect</td></tr><tr><td>2. Nightly snapshots</td><td>A USB disk on the server, 14–30 days of history</td><td>deleted or overwritten files, broken updates, a dead server SSD</td></tr><tr><td>3. Pulled copy</td><td>An encrypted SSD that a laptop fills itself</td><td>a dead backup disk, a hacked server, ransomware, mistakes with the snapshots</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Honestly, the third copy is in the same house. Against fire or burglary it doesn&#8217;t help; for that, the SSD would have to live somewhere else, or an encrypted cloud copy would be the next step. For everything else, these three layers have covered every real problem I&#8217;ve had so far.</p>



<h2 class="wp-block-heading">2. What gets backed up</h2>



<p class="wp-block-paragraph">Every service has its own small script and its own time slot, so a failing job never blocks the others, and the heavy Nextcloud job runs last:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Service</th><th>What is saved</th><th>Kept</th><th>Downtime</th></tr></thead><tbody><tr><td>WordPress sites (web host)</td><td>Files via tar over SSH + MySQL dump</td><td>30 days</td><td>none</td></tr><tr><td>Paperless-ngx</td><td>PostgreSQL dump + all documents + search index</td><td>30 days</td><td>none</td></tr><tr><td>Authentik (single sign-on)</td><td>PostgreSQL dump + compose folder incl. <code>.env</code></td><td>30 days</td><td>none</td></tr><tr><td>Infrastructure</td><td>Traefik (incl. certificates), CrowdSec config, Portainer, SSH config, the backup scripts themselves</td><td>60 days</td><td>Portainer paused ~1 s</td></tr><tr><td>Zigbee2MQTT</td><td>The whole data folder (network key, device database)</td><td>30 days</td><td>paused a few seconds</td></tr><tr><td>BookStack wiki</td><td>MariaDB dump + uploads + <code>.env</code></td><td>30 days</td><td>none</td></tr><tr><td>PostgreSQL for Home Assistant</td><td>Dump of every database + roles</td><td>14 days</td><td>none</td></tr><tr><td>Vaultwarden</td><td>Consistent SQLite copy + keys + attachments</td><td>30 days</td><td>none</td></tr><tr><td>Nextcloud</td><td>All user files + PostgreSQL dump + config volume</td><td>14 days</td><td>maintenance mode during the run</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Not backed up on purpose: logs, caches, message queues, CrowdSec&#8217;s ban list (it rebuilds itself), and the live database folders. Those are useless while the database runs; the dumps are the 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>Tip:</strong> Home Assistant&#8217;s own backups do <em>not</em> include an external recorder database. If you moved your history to PostgreSQL or MariaDB like I did, you need a separate dump of it, or years of statistics are gone with the next disk failure.</p>
</div>



<h2 class="wp-block-heading">3. Snapshots with rsync hardlinks</h2>



<p class="wp-block-paragraph">Every run creates a folder named after the date, like <code>2026-10-20_0030</code>. Each of these folders looks like a <strong>complete copy</strong>: you can browse it, copy single files out of it, or restore all of it. But files that didn&#8217;t change since the previous night aren&#8217;t copied again. They are <em>hardlinks</em> to the same file in the previous snapshot, so they take no extra disk space. That&#8217;s what rsync&#8217;s <code>--link-dest</code> option does:</p>



<pre class="wp-block-code"><code>/backup/paperless_backup/
├── 2026-10-19_0030/
│   ├── paperless-db.dump   database dump
│   ├── media/              all documents (mostly hardlinks)
│   ├── data/               search index, classifier
│   ├── compose/            docker-compose.yml, .env
│   └── rsync.log
├── 2026-10-20_0030/
├── latest -&gt; 2026-10-20_0030
└── backup-paperless.sh    the script copies itself here</code></pre>



<ul class="wp-block-list">
<li><strong>Deleting an old snapshot is safe.</strong> A file only disappears from the disk when the last snapshot pointing to it is deleted.</li>



<li><strong>The filesystem must support hardlinks:</strong> ext4, XFS or btrfs. Not exFAT or NTFS – the typical format of a new USB disk.</li>



<li><strong>Hardlinks are not extra copies.</strong> Thirty snapshots of an unchanged document are still one file on the disk. If that disk sector dies, all thirty are affected. That&#8217;s why the third copy exists.</li>



<li><strong>The script copies itself</strong> next to the snapshots, so the instructions are on the backup disk even when the server is gone.</li>
</ul>



<h2 class="wp-block-heading">4. Databases are dumped, never copied</h2>



<p class="wp-block-paragraph">Copying the files of a running database gives you a snapshot of a moving target: half-written pages, a write-ahead log that doesn&#8217;t match. It may restore, or it may not. A dump asks the database itself for a consistent export, while it keeps running. And every dump is checked right after it is written:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Database</th><th>Dump</th><th>Check</th></tr></thead><tbody><tr><td>PostgreSQL</td><td><code>pg_dump -Fc</code> (compressed custom format)</td><td><code>pg_restore --list</code> must be able to read it</td></tr><tr><td>MariaDB / MySQL</td><td><code>mariadb-dump</code> / <code>mysqldump</code>, gzipped</td><td><code>gzip -t</code> and the line &#8220;Dump completed&#8221; at the end</td></tr><tr><td>SQLite (Vaultwarden)</td><td><code>sqlite3 … ".backup"</code> (consistent online copy)</td><td><code>PRAGMA integrity_check</code></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">A dump that fails its check stops the whole job, and the night&#8217;s snapshot never becomes <code>latest</code> (next section). A corrupt backup is worse than none, because you only find out when you need it.</p>



<h2 class="wp-block-heading">5. One backup script, explained</h2>



<p class="wp-block-paragraph">All my scripts follow the same pattern. Here is the Paperless one, slightly shortened:</p>



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

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

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

# 1. never fill the system disk if the backup disk is missing
mountpoint -q /backup || fail "/backup is not mounted"

mkdir -p "$DEST" &amp;&amp; chmod 700 "$DEST"
rm -rf "$DEST"/*.partial               # leftovers of a failed run
mkdir -p "$WORK"

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

# 2. database dump + check
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. files, unchanged ones as hardlinks to the last snapshot
rsync -aHAX --numeric-ids "${LINK[@]}" "$PROJECT/data/media/" "$WORK/media/"

# 4. only now does the snapshot become real
mv "$WORK" "$SNAP"
ln -sfn "$STAMP" "$DEST/latest"
cp -f "$0" "$DEST/"

# 5. retention
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> any failing command stops the script. A backup script that carries on after an error produces backups that look fine and aren&#8217;t.</li>



<li><strong><code>mountpoint -q</code>:</strong> if the USB disk isn&#8217;t mounted, <code>/backup</code> is just an empty folder on the system disk. Without this check, the first night after a disk failure fills your system SSD.</li>



<li><strong>The <code>.partial</code> folder:</strong> the snapshot is built under a temporary name and only renamed when everything succeeded. A failed run never becomes <code>latest</code>, so the next run still links against the last good one.</li>



<li><strong><code>rsync -aHAX --numeric-ids</code>:</strong> keeps permissions, hardlinks, ACLs, extended attributes and the numeric owner IDs. Containers often run as user IDs that don&#8217;t exist on the host; with names instead of numbers, a restore would get the owners wrong.</li>



<li><strong>Retention by folder age:</strong> <code>find -mtime +30</code> removes snapshots older than 30 days. Thanks to hardlinks, that&#8217;s always safe.</li>
</ul>



<h2 class="wp-block-heading">6. Scheduling with systemd timers</h2>



<p class="wp-block-paragraph">I use systemd timers instead of cron. They log every run in the journal, they catch up on runs that were missed while the server was off (<code>Persistent=true</code>), and a service can wait until the backup disk is mounted:</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">Enable it with <code>systemctl enable --now backup-paperless.timer</code>. <code>systemctl list-timers</code> shows when each job ran last and runs next, and <code>journalctl -u backup-paperless</code> shows its output. My jobs start between 23:30 and 03:00, at least 15 minutes apart, so they never compete for the disk.</p>



<h2 class="wp-block-heading">7. Services that need special care</h2>



<ul class="wp-block-list">
<li><strong>Nextcloud:</strong> the job switches on maintenance mode, dumps the database, copies the files and switches it off again. Otherwise someone could upload a file between the dump and the file copy, and the two wouldn&#8217;t match. The copy runs with an rsync bandwidth limit (<code>--bwlimit</code>) so the disks aren&#8217;t saturated, and at 03:00, when nobody uploads anything. On a normal night that&#8217;s a few minutes of maintenance mode.</li>



<li><strong>Zigbee2MQTT:</strong> its folder holds the network key and the database of all paired devices. Without it, every device has to be paired again. The container is paused (<code>docker pause</code>, not stopped) for the few seconds of the copy, so the database file is consistent.</li>



<li><strong>Authentik and BookStack:</strong> the database alone is not enough. Both keep a secret key in their <code>.env</code> that signs sessions or encrypts data. Restore the dump <em>together with</em> the <code>.env</code> from the same night, or logins and encrypted fields stop working.</li>



<li><strong>The reverse proxy:</strong> Traefik&#8217;s <code>acme.json</code> holds all certificates and the Let&#8217;s Encrypt account key (see my <a href="/traefik-reverse-proxy-docker/">Traefik article</a>). Without it, Traefik requests every certificate again and can run into rate limits.</li>



<li><strong>Sites on a web host:</strong> the server logs in with an SSH key, streams the WordPress folder as a tar archive and dumps the database on the host. The host&#8217;s own backups are a bonus, not a 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>Your backup disk is full of secrets:</strong> password vault, database passwords, signing keys, the Zigbee network key. Treat it like the server itself: physically safe, folders readable only by root (<code>chmod 700</code>), and the copy that leaves the server encrypted.</p>
</div>



<h2 class="wp-block-heading">8. The third copy: pulled, read-only, encrypted</h2>



<p class="wp-block-paragraph">The obvious way to make a second copy is to let the server push it somewhere. The problem: if the server is hacked or hit by ransomware, the attacker has the same access and can delete or encrypt that copy too. So my third copy works the other way round. A laptop <strong>pulls</strong> the newest snapshot of every job onto an encrypted USB SSD, and the server has no way to reach it:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Decision</th><th>Why</th></tr></thead><tbody><tr><td>Pull instead of push</td><td>The server has no login on the laptop at all. A compromised server cannot touch the copy.</td></tr><tr><td>Read-only key on the server</td><td>A dedicated user may only run <code>rrsync -ro</code> on the backup folder. The laptop can read, never write or delete.</td></tr><tr><td>New dated snapshot per run, <strong>never <code>--delete</code></strong></td><td>If snapshots disappear on the server (by mistake or on purpose), the older copies on the SSD stay.</td></tr><tr><td>Same folder layout</td><td>The restore script works on the SSD unchanged.</td></tr><tr><td>LUKS encryption with a keyfile</td><td>Unlocked only during a run, closed afterwards. A lost SSD is unreadable.</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The <code>rrsync</code> restriction is one line in the server&#8217;s <code>authorized_keys</code>:</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">The pull job on the laptop runs every hour but only does something when it makes sense: at most once a day, only on mains power, never while the server&#8217;s own backups run (23:15–04:30), and only when the SSD is plugged in and the server answers. It also checks what it copies:</p>



<ul class="wp-block-list">
<li><strong>Spot checks:</strong> dumps not empty, gzip archives readable, the Vaultwarden database passes its integrity check, the certificate file isn&#8217;t empty.</li>



<li><strong>Ransomware alarm:</strong> before copying Nextcloud, a dry run counts how many files changed. If it&#8217;s more than 20 %, something is very wrong (that&#8217;s what mass encryption looks like). The copy is kept, but old generations are no longer deleted until I look at it.</li>



<li><strong>Longer history than on the server:</strong> the newest 7 copies, then one per week for 8 weeks and one per month for 12 months. Nextcloud, at about 800 GB, keeps 10 generations.</li>
</ul>



<h2 class="wp-block-heading">9. Knowing it worked</h2>



<p class="wp-block-paragraph">A backup that silently stopped three weeks ago is the most common way to lose data. So a small Python script on the server collects the state of every job every five minutes and writes it to a JSON file: last run, result, age of the newest snapshot, whether the timer is enabled, whether the backup disk is mounted.</p>



<ul class="wp-block-list">
<li><strong>A small status page</strong> on the server shows it as a table.</li>



<li><strong>Home Assistant</strong> reads the same file and creates a sensor per job (<code>ok</code>, <code>failed</code>, <code>stale</code>, …). If any job has a problem for ten minutes, I get a push notification and a Telegram message. &#8220;Stale&#8221; means the newest snapshot is older than 26 hours, which also catches a timer that simply never ran.</li>



<li><strong>Coverage check:</strong> the script also lists every <em>running</em> container and compares it with a list of covered services. A new container without a backup job shows up as a warning. That closed the gap I worried about most: adding a service on a Sunday afternoon and forgetting its backup.</li>
</ul>



<p class="wp-block-paragraph">The coverage list comes with a house rule: every new container gets its backup job, its restore step and its coverage entry <em>in the same change</em>. &#8220;Later&#8221; is how backups get forgotten.</p>



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



<p class="wp-block-paragraph">First, the cheap way: most &#8220;I deleted something&#8221; moments are solved in the app itself. Nextcloud has deleted files and versions, Paperless a trash for 30 days. Only when that doesn&#8217;t help do I go to the snapshots, and a single file is just a copy out of the right date folder.</p>



<p class="wp-block-paragraph">For the bad day, when the server is gone or several services are broken, there is an interactive restore script on the backup disk itself:</p>



<ol class="wp-block-list">
<li><strong>Choose jobs:</strong> one, several or all, in a fixed order: infrastructure first (reverse proxy, certificates), then single sign-on, then the databases and apps.</li>



<li><strong>Choose the snapshot:</strong> the newest, one date for all, or per job.</li>



<li><strong>Choose steps per job:</strong> files, database, containers.</li>



<li><strong>Dry run:</strong> the script prints every command it would run, and only continues when I type <code>RESTORE</code>.</li>
</ol>



<p class="wp-block-paragraph">It never deletes anything: files it overwrites are moved into a <code>.pre-restore-&lt;time&gt;</code> folder, and every database is dumped before it is replaced. The backup timers are paused during the restore, so no backup job runs against a half-restored system.</p>



<p class="wp-block-paragraph">A restore script you never ran is a hope, not a plan. Mine has a test bench: fake <code>docker</code>, <code>systemctl</code> and <code>sshd</code> commands and fake snapshots, so it can run every scenario without touching real data. It&#8217;s 211 checks, and it has to pass after every change to the restore script. It still doesn&#8217;t replace a real restore on a spare machine once in a while: that&#8217;s the only proof that counts.</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>Tip:</strong> write down where the restore instructions are, and make sure they&#8217;re on the backup disk and on the offline copy, not only on the server you might be restoring. Mine are a README next to the snapshots, a copy of every script in its backup folder, and the restore script itself.</p>
</div>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li><strong>Boring tools win.</strong> rsync, bash and systemd will still be there in ten years, and I can restore with nothing but a shell.</li>



<li><strong>Dump databases and check the dump.</strong> A copied database folder is a gamble; an unchecked dump is a surprise waiting to happen.</li>



<li><strong>Snapshots aren&#8217;t copies.</strong> Hardlinks give you history, not redundancy. You still need a second disk.</li>



<li><strong>Pull, don&#8217;t push, the copy that has to survive an attack.</strong> Whatever the server can reach, an attacker on the server can reach too.</li>



<li><strong>Monitor the backups, not just the services.</strong> The alert for &#8220;no new snapshot in 26 hours&#8221; is worth more than any feature of the backup itself.</li>



<li><strong>Secrets belong to the data.</strong> Keys and <code>.env</code> files from the same night as the database, or the restore looks fine and nothing works.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Hardening Traefik: geo-blocking, CrowdSec and single sign-on</title>
		<link>https://selfhostjournal.eu/traefik-hardening-geoblock-crowdsec-sso/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 13:19:17 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<category><![CDATA[Security and SSO]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=134</guid>

					<description><![CDATA[Part 2 of my Traefik setup: GeoBlock for the countries you need, CrowdSec with its web application firewall, Authentik forward auth in front of apps, container basics and how to test it all.]]></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">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> As soon as ports 80 and 443 are open, bots from all over the world scan your services, try passwords and probe for known vulnerabilities. Traefik alone forwards all of it.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> Several layers in front of every service: geo-blocking for countries you never visit from, CrowdSec for known attackers and attack patterns, single sign-on in front of apps without their own good login, plus a few container basics.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Traefik 3.7, GeoBlock plugin 0.3.8, CrowdSec 1.8 with the Traefik bouncer plugin 1.7 and AppSec, Authentik 2026.8, Docker Compose on a small home server</p>
</div>



<p class="wp-block-paragraph">This is the second part of my Traefik setup. The <a href="/traefik-reverse-proxy-docker/">first part</a> explains the basics: entrypoints, routers, labels and certificates. Here it&#8217;s about the question Traefik itself doesn&#8217;t answer: should this request get in at all? A reverse proxy decides <em>where</em> a request goes. The layers in this article decide <em>whether</em>.</p>



<h2 class="wp-block-heading">The layers at a glance</h2>



<p class="wp-block-paragraph">Every request to port 443 passes these checks in order. Each one is cheap, and each one stops a different kind of visitor:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>#</th><th>Layer</th><th>Stops</th><th>Applies to</th></tr></thead><tbody><tr><td>1</td><td>GeoBlock</td><td>Everything from countries I don&#8217;t allow</td><td>all services</td></tr><tr><td>2</td><td>CrowdSec (IP reputation)</td><td>IP addresses known for attacks, and IPs that misbehave on my server</td><td>all services</td></tr><tr><td>3</td><td>CrowdSec AppSec (WAF)</td><td>Requests that look like attacks: SQL injection, path traversal, known exploits</td><td>all services</td></tr><tr><td>4</td><td>Single sign-on (Authentik)</td><td>Everyone who isn&#8217;t logged in</td><td>apps without their own good login</td></tr><tr><td>5</td><td>The app&#8217;s own login</td><td>Everyone without an account</td><td>each app</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">No single layer is perfect. GeoBlock is fooled by a VPN, CrowdSec doesn&#8217;t know every attacker, and every login can have a bug. Together, they turn &#8220;anyone on the internet&#8221; into &#8220;a few people, in a few countries, with a valid login&#8221;.</p>



<h2 class="wp-block-heading">1. One line for all services</h2>



<p class="wp-block-paragraph">You could add middlewares to every router separately. I attach the two global ones to the <code>websecure</code> entrypoint instead, in Traefik&#8217;s static configuration. That way no service can be forgotten, including the ones I add next year:</p>



<pre class="wp-block-code"><code>    command:
      # ... the configuration from part 1 ...

      # Security for EVERY site on 443: first GeoBlock, then CrowdSec, then security headers (step 5).
      # To switch both off quickly: comment out this line and run docker compose up -d
      - "--entrypoints.websecure.http.middlewares=geoblock@docker,crowdsec@docker,secheaders@docker"

      # The two plugins, downloaded from plugins.traefik.io at start
      - "--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>The order matters:</strong> GeoBlock runs first because it&#8217;s the cheapest check. Requests from blocked countries never reach CrowdSec, which keeps its logs and work small.</li>



<li><strong><code>@docker</code></strong> means the middleware is defined with Docker labels, here on the Traefik container itself (next sections).</li>



<li><strong>Plugin versions are pinned</strong>, for the same reason as the Traefik image in part 1: a new plugin version can change options.</li>



<li><strong>Port 80 is not covered,</strong> and that&#8217;s fine: it only answers with a redirect to HTTPS, which then passes every check.</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>Tip:</strong> the comment above the line is deliberate. If you ever lock yourself out (for example while travelling), one commented-out line and a restart turn all filtering off. Write that down somewhere you&#8217;ll find it under stress.</p>
</div>



<h2 class="wp-block-heading">2. GeoBlock: only the countries you need</h2>



<p class="wp-block-paragraph">My services are used by my family, all of us in two European countries. There&#8217;s no reason for anyone in other countries to reach my login pages. GeoBlock looks up the country of each visitor&#8217;s IP address and only lets the allowed ones through. These labels go on the Traefik container:</p>



<pre class="wp-block-code"><code>    labels:
      # --- GeoBlock: only allow listed countries (+ private/home 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"

      # Exceptions: a service that has to reach you from abroad
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowedIPAddresses[0]=203.0.113.0/24"

      # Country lookup
      - "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: only denied requests, into their own file
      - "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> the allowed countries as two-letter codes. Mine are different; pick your own. With <code>blackListMode=false</code> this is an allow list: everything not listed is blocked. The opposite (block a few countries, allow the rest) protects much less.</li>



<li><strong><code>allowLocalRequests=true</code>:</strong> private addresses (your home network) are always allowed. Without it, you lock yourself out at home as soon as a request arrives with a local address.</li>



<li><strong><code>allowUnknownCountries=false</code>:</strong> if the lookup finds no country, the request is blocked. Safer, at the price of a rare false block.</li>



<li><strong><code>allowedIPAddresses</code>:</strong> exceptions for services that call you from another country. In my case that&#8217;s an AI assistant&#8217;s connector to Home Assistant, which comes from the provider&#8217;s published IP range in the US. Only allow ranges the provider publishes officially, never a guessed one.</li>



<li><strong>Lookup and cache:</strong> unknown IP addresses are looked up with an online service (<code>geojs.io</code> here), at most 750 ms. The last 100 results are cached, so repeat visitors don&#8217;t cause new lookups.</li>



<li><strong>Log file:</strong> every blocked request ends up in <code>geoblock.log</code>. It&#8217;s surprisingly instructive to read it after a day: most of it is automated scanning.</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>Think about this before you switch it on:</strong> you&#8217;ll be blocked yourself when you travel to another country, unless you connect through a VPN to your home network first. And the country lookup sends visitors&#8217; IP addresses to an external service. For a private homelab that only family uses, that&#8217;s fine for me; for a public website with visitors, it belongs in the privacy policy, or you use an offline country database instead.</p>
</div>



<p class="wp-block-paragraph">Let&#8217;s Encrypt still works with GeoBlock on. The TLS challenge from part 1 is answered by Traefik before any middleware runs, so the validation servers abroad aren&#8217;t blocked.</p>



<h2 class="wp-block-heading">3. CrowdSec: known attackers and attack patterns</h2>



<p class="wp-block-paragraph">CrowdSec does two things. It reads Traefik&#8217;s access log and notices when an IP address behaves like an attacker (many failed logins, scanning for <code>/wp-login.php</code> on a server without WordPress, and so on). And it shares this with a community list, so you also block addresses that attacked someone else yesterday. In addition, its AppSec component works as a web application firewall (WAF): it checks each request against rules for common attacks before Traefik forwards it.</p>



<p class="wp-block-paragraph">There are two parts: the CrowdSec engine (its own container) and a <em>bouncer</em> inside Traefik that asks the engine about every request.</p>



<h3 class="wp-block-heading">The engine</h3>



<pre class="wp-block-code"><code>  crowdsec:
    image: crowdsecurity/crowdsec:v1.8.1
    container_name: crowdsec
    restart: unless-stopped
    environment:
      # Detection rules: Traefik log parser, common web attacks, known CVEs, AppSec (WAF)
      COLLECTIONS: "crowdsecurity/traefik crowdsecurity/base-http-scenarios crowdsecurity/http-cve crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
      # Registers the Traefik bouncer with the key from .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
    # No published ports: Traefik reaches it as crowdsec:8080 and crowdsec:7422</code></pre>



<ul class="wp-block-list">
<li><strong>Collections</strong> are rule bundles from the CrowdSec hub: a parser for Traefik&#8217;s log format, scenarios for typical web attacks and known vulnerabilities (CVEs), and the AppSec rule sets.</li>



<li><strong><code>BOUNCER_KEY_traefik</code></strong> registers Traefik as a bouncer with a key you choose. Create one and put it in the <code>.env</code> file next to the compose file, never in the compose file itself: <code>echo "CROWDSEC_BOUNCER_KEY=$(openssl rand -hex 32)" &gt;&gt; .env</code>.</li>



<li><strong>Traefik&#8217;s log folder</strong> is mounted read-only, so CrowdSec can read the access log from part 1.</li>



<li><strong>No published ports:</strong> only Traefik talks to CrowdSec, through the shared <code>proxy</code> network.</li>
</ul>



<p class="wp-block-paragraph">What CrowdSec reads is defined in <code>crowdsec/acquis.d/traefik.yaml</code>:</p>



<pre class="wp-block-code"><code># 1. Traefik access log: brute force, scans, known exploits
filenames:
  - /var/log/traefik/access.log
labels:
  type: traefik
---
# 2. AppSec (WAF): Traefik sends every request here before passing it on
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">The 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"
      # never ban the home network
      - "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> the bouncer downloads the full list of blocked addresses every 60 seconds and checks requests locally. That&#8217;s fast, and Traefik doesn&#8217;t have to ask CrowdSec for each request.</li>



<li><strong><code>crowdsecAppsecFailureBlock=true</code>:</strong> if AppSec can&#8217;t decide about a request (an error in the check), the request is blocked.</li>



<li><strong><code>crowdsecAppsecUnreachableBlock=false</code>:</strong> if the CrowdSec container is down, requests are let through. That&#8217;s a deliberate trade-off: during a CrowdSec update, my services stay online and GeoBlock still filters. If you&#8217;d rather be offline than unprotected, set it to <code>true</code>.</li>



<li><strong><code>clientTrustedIPs</code>:</strong> your home network is never banned. Without it, a family member typing the wrong password five times can lock out the whole house.</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>Tip:</strong> Docker Compose fills <code>${CROWDSEC_BOUNCER_KEY}</code> from the <code>.env</code> file in labels too, so the key appears in neither file you&#8217;d share or back up publicly. Keep <code>.env</code> out of any Git repository.</p>
</div>



<h3 class="wp-block-heading">Everyday commands</h3>



<pre class="wp-block-code"><code># which addresses are blocked right now, and why?
docker exec crowdsec cscli decisions list

# what was detected recently?
docker exec crowdsec cscli alerts list

# is the log being read, are the bouncer and AppSec talking to CrowdSec?
docker exec crowdsec cscli metrics

# unblock an address (e.g. a friend who got banned)
docker exec crowdsec cscli decisions delete --ip 198.51.100.7

# block an address by hand for 24 hours
docker exec crowdsec cscli decisions add --ip 198.51.100.7 --duration 24h</code></pre>



<p class="wp-block-paragraph">The access and GeoBlock logs grow every day, so I rotate them with logrotate on the host (7 days, compressed). <code>copytruncate</code> keeps the file in place, so neither Traefik nor CrowdSec needs a restart:</p>



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



<h2 class="wp-block-heading">4. Single sign-on in front of apps</h2>



<p class="wp-block-paragraph">Some apps have no login of their own, or a weak one: Traefik&#8217;s dashboard, the Zigbee2MQTT frontend, small admin tools. For those, Traefik asks my identity provider Authentik before every request: is this person logged in, and allowed to use this app? If not, they see Authentik&#8217;s login page, with two-factor authentication. This is called <em>forward auth</em>.</p>



<p class="wp-block-paragraph">The middleware is defined once, with labels on the Authentik server container:</p>



<pre class="wp-block-code"><code>    labels:
      # Embedded outpost path (needed for forward auth on other 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 for apps WITHOUT their own 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>The outpost router</strong> catches the path <code>/outpost.goauthentik.io/</code> on <em>every</em> host name and sends it to Authentik. That&#8217;s where the login redirect comes back to. <code>priority=1000</code> makes sure it wins against the app&#8217;s own router.</li>



<li><strong><code>forwardauth.address</code>:</strong> Traefik asks this URL about every request. Authentik answers &#8220;yes&#8221; (the request continues) or with a redirect to the login page.</li>



<li><strong><code>authResponseHeaders</code>:</strong> after a successful login, Authentik passes the user name, groups and e-mail to the app as headers. Apps that understand them can log the user in automatically.</li>
</ul>



<p class="wp-block-paragraph">Protecting an app is then one more label on its router, here for the Traefik dashboard from part 1:</p>



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



<p class="wp-block-paragraph">In Authentik, each protected app needs a <em>Proxy Provider</em> in forward-auth mode (single application) and an application that is bound to the users or groups allowed in. Without that, Authentik doesn&#8217;t know the host name and refuses everyone.</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>Not for every app:</strong> forward auth only works for browsers. Mobile apps and other clients that talk to an API (the Home Assistant app, password manager apps, sync clients) can&#8217;t show Authentik&#8217;s login page and simply stop working. For those apps I use their native single sign-on through OpenID Connect where they support it (I explained it for WordPress in <a href="/wordpress-sso-authentik/">this article</a>), and otherwise rely on their own login plus the layers above.</p>
</div>



<h2 class="wp-block-heading">5. Security headers for every service</h2>



<p class="wp-block-paragraph">Security headers are short instructions that the server sends to the browser with each page. Many apps set some of them, but rarely all, and every app does it differently. A third middleware on the entrypoint adds the important ones to every service and removes the headers that only tell attackers which software and version you run. These labels go on the Traefik container, and the entrypoint line from step 1 gets a third entry: <code>geoblock@docker,crowdsec@docker,secheaders@docker</code>.</p>



<pre class="wp-block-code"><code>    labels:
      # --- Security headers for every site on 443 ---
      # HSTS: browsers use https only, for 1 year
      - "traefik.http.middlewares.secheaders.headers.stsSeconds=31536000"
      - "traefik.http.middlewares.secheaders.headers.contentTypeNosniff=true"
      # Hide software names and versions (an empty value removes the 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> after the first visit, the browser refuses to open the site over plain HTTP for one year, even if someone tries to redirect it there in a public Wi-Fi. I left out <code>includeSubdomains</code> and <code>preload</code> on purpose: they also bind every other subdomain of your domain to HTTPS, and preload is very hard to undo.</li>



<li><strong><code>contentTypeNosniff</code>:</strong> the browser trusts the declared file type and doesn&#8217;t guess. That stops an uploaded &#8220;image&#8221; from being run as a script.</li>



<li><strong>Removing <code>Server</code> and <code>X-Powered-By</code>:</strong> before, my services told everyone things like <code>Server: nginx</code> or <code>X-Powered-By: PHP/8.5</code>. That doesn&#8217;t make anything vulnerable, but it saves an attacker the first step of looking up exploits for exactly that version.</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>Not every header belongs here:</strong> <code>X-Frame-Options</code> and a Content Security Policy depend on the app. My office suite is shown inside Nextcloud in a frame; a central &#8220;never show in a frame&#8221; header would break it. Those I leave to the apps, which mostly set them correctly anyway. After adding the middleware, open every service once and check that it still works.</p>
</div>



<p class="wp-block-paragraph">Checking takes one command per service. You should see <code>strict-transport-security</code> and <code>x-content-type-options</code>, and no <code>server</code> line:</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. Container basics</h2>



<p class="wp-block-paragraph">A few small settings on the containers themselves, which cost nothing:</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> processes in the container can&#8217;t gain more rights later (for example through a setuid program). If someone ever breaks into Traefik, this makes the next step harder.</li>



<li><strong>Docker socket read-only:</strong> Traefik only needs to read container information. Be aware that <code>:ro</code> only protects the socket <em>file</em>; the Docker API behind it still accepts commands. A stricter solution is a socket proxy, a small container that only lets through the read requests Traefik needs. I use one for my monitoring portal and plan to switch Traefik to it too.</li>



<li><strong>No published ports</strong> on any service except Traefik (80 and 443). Everything else is only reachable through Traefik and therefore through all the layers above. A forgotten <code>ports:</code> line bypasses all of them.</li>



<li><strong><code>exposedbydefault=false</code></strong> from part 1 belongs here too: nothing is public by accident.</li>
</ul>



<h2 class="wp-block-heading">7. Testing that it works</h2>



<p class="wp-block-paragraph">Don&#8217;t trust a security setup you haven&#8217;t tested. These checks take five minutes. Run the attack tests from <em>outside</em> your home network (mobile data, not Wi-Fi), because your home network is on every allow list:</p>



<pre class="wp-block-code"><code># AppSec should block typical attack requests with 403
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"

# did CrowdSec see it?
docker exec crowdsec cscli alerts list | head

# GeoBlock: requests from other countries show up here
tail -n 5 traefik-logs/geoblock.log

# forward auth: without a login you should be sent to Authentik (302)
curl -s -o /dev/null -w "%{http_code} %{redirect_url}n" https://traefik.example.com/</code></pre>



<p class="wp-block-paragraph">To test GeoBlock from another country, use a VPN with an exit in a blocked country, or one of the many online &#8220;check website from another location&#8221; services. You should get a <code>403</code>.</p>



<h2 class="wp-block-heading">What I don&#8217;t do (yet)</h2>



<ul class="wp-block-list">
<li><strong>Central frame and content policies</strong> (<code>X-Frame-Options</code>, Content Security Policy): they depend too much on each app, so the apps set them themselves (see step 5).</li>



<li><strong>Custom TLS options:</strong> not needed for me. I checked with <code>openssl s_client</code>: Traefik&#8217;s defaults already refuse TLS 1.0 and 1.1 and only accept 1.2 and 1.3.</li>



<li><strong>Rate limiting:</strong> Traefik&#8217;s <code>ratelimit</code> middleware on login pages. CrowdSec covers most of it by banning brute-force attempts.</li>



<li><strong>The socket proxy for Traefik,</strong> as mentioned above.</li>
</ul>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li><strong>Entrypoint middlewares beat per-router middlewares:</strong> new services are protected from their first second, without me remembering anything.</li>



<li><strong>Allow lists beat block lists.</strong> Listing the two countries I need is safer and simpler than listing the hundred I don&#8217;t.</li>



<li><strong>Always exempt the home network</strong>, in GeoBlock and in CrowdSec. Otherwise the first person locked out is you.</li>



<li><strong>Decide what happens when a security layer fails:</strong> block everything, or let traffic through. Both are valid, but it should be a decision, not a default you never looked at.</li>



<li><strong>Test from outside.</strong> Everything looks secure from your own sofa, because your home network is on every allow list.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Traefik as a reverse proxy for Docker: my configuration explained</title>
		<link>https://selfhostjournal.eu/traefik-reverse-proxy-docker/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 11:47:27 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=125</guid>

					<description><![CDATA[One Traefik container in front of all self-hosted services: entrypoints, labels, routes for VMs, Let's Encrypt certificates and the dashboard, explained line by line.]]></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">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Every self-hosted service wants its own port, its own certificate and its own way to be reached from outside. After the third service, that gets messy.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> One Traefik container in front of everything. It answers on ports 80 and 443, gets Let&#8217;s Encrypt certificates on its own, and sends each request to the right service, based on a few labels in each <code>docker-compose.yml</code>.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Traefik 3.7, Docker Compose on a small home server, Let&#8217;s Encrypt, about ten services (Docker containers and one Home Assistant VM)</p>
</div>



<p class="wp-block-paragraph">Traefik is the front door of my homelab. Every request from the internet arrives there first, and Traefik decides which service gets it. This article explains my configuration line by line: how Traefik is set up, how a container gets published with labels, how services outside Docker are added, and how the certificates work. Security hardening (geo-blocking, CrowdSec, single sign-on in front of services) is left out on purpose. It gets <a href="/traefik-hardening-geoblock-crowdsec-sso/">its own article</a>, because it builds on everything here.</p>



<h2 class="wp-block-heading">1. How Traefik thinks</h2>



<p class="wp-block-paragraph">Traefik has four building blocks. Once these are clear, every config line makes sense:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Part</th><th>What it does</th><th>In my setup</th></tr></thead><tbody><tr><td>Entrypoint</td><td>A port Traefik listens on</td><td><code>web</code> (80) and <code>websecure</code> (443)</td></tr><tr><td>Router</td><td>A rule that matches requests, usually by host name</td><td>one per service, e.g. <code>Host(`vault.example.com`)</code></td></tr><tr><td>Middleware</td><td>Something that changes or checks a request on the way</td><td>the HTTP-to-HTTPS redirect (more in the hardening article)</td></tr><tr><td>Service</td><td>Where the request finally goes</td><td>a container port or a URL in the local network</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">There are also two kinds of configuration. The <strong>static configuration</strong> is read once at start: entrypoints, certificate settings, where to find routes. The <strong>dynamic configuration</strong> is the routes themselves. Traefik reads it from Docker labels and from files, and picks up changes without a restart.</p>



<h2 class="wp-block-heading">2. Before you start</h2>



<ol class="wp-block-list">
<li><strong>DNS:</strong> an A record for every host name (for example <code>vault.example.com</code>) that points to your public IP address. A wildcard record works too.</li>



<li><strong>Router:</strong> forward ports 80 and 443 from your internet router to the Docker host. Nothing else needs to be open.</li>



<li><strong>A shared Docker network</strong> that Traefik and all published containers join:</li>
</ol>



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



<ol start="4" class="wp-block-list">
<li><strong>A file for the certificates.</strong> Traefik refuses to use it if other users can read it:</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. The Traefik container</h2>



<p class="wp-block-paragraph">I put the whole static configuration into the <code>command</code> section of the compose file. A separate <code>traefik.yml</code> works just as well; I like having everything in one place.</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:
      # General
      - "--global.checknewversion=false"
      - "--global.sendanonymoususage=false"
      - "--log.level=INFO"
      - "--accesslog=true"
      - "--accesslog.filepath=/var/log/traefik/access.log"
      - "--api.dashboard=true"

      # Where routes come from
      - "--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"

      # Send everything from http to https
      - "--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"

      # No timeout for long 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">What the lines do:</p>



<ul class="wp-block-list">
<li><strong>Docker socket:</strong> Traefik watches Docker to see which containers start and stop and which labels they have. Read-only is enough. How to protect this further is part of the hardening article.</li>



<li><strong><code>exposedbydefault=false</code>:</strong> only containers with the label <code>traefik.enable=true</code> get published. Without this, every container you start would be reachable from the internet.</li>



<li><strong><code>providers.docker.network=proxy</code>:</strong> Traefik talks to containers through the shared <code>proxy</code> network. This matters when a container is in more than one network: without it, Traefik may pick an address it can&#8217;t reach.</li>



<li><strong>File provider:</strong> every <code>.yml</code> file in <code>/dynamic</code> is read as extra routes, and <code>watch=true</code> applies changes on save. That&#8217;s how services outside Docker get in (step 5).</li>



<li><strong>Redirect:</strong> a request to <code>http://</code> is answered with a redirect to <code>https://</code>. No service needs its own redirect.</li>



<li><strong>Access log:</strong> one line per request. It&#8217;s the first place to look when something doesn&#8217;t arrive, and later the input for intrusion detection.</li>



<li><strong><code>readTimeout=0</code>:</strong> Traefik&#8217;s default read timeout cut off large Nextcloud uploads. Zero means no limit. If you don&#8217;t upload big files, leave it out.</li>
</ul>



<h3 class="wp-block-heading">Which version tag to use</h3>



<p class="wp-block-paragraph">The part after the colon in <code>image: traefik:v3.7</code> is the <strong>tag</strong>. It decides which Traefik version Docker downloads, and which version you get after the next <code>docker compose pull</code>. Traefik publishes the same release under several tags:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Tag</th><th>What you get on the next pull</th><th>Risk</th></tr></thead><tbody><tr><td><code>latest</code></td><td>Always the newest release, including the next major version (v4 one day)</td><td>Highest: a major version can remove or rename options, and your routes stop working</td></tr><tr><td><code>v3</code></td><td>Every new 3.x release (3.7, 3.8, …)</td><td>Low: minor versions add features and only rarely change behaviour</td></tr><tr><td><code>v3.7</code></td><td>Only bug and security fixes of 3.7 (3.7.16, 3.7.17, …)</td><td>Lowest, but you have to change the tag yourself to move to 3.8</td></tr><tr><td><code>v3.7.16</code></td><td>Exactly this version, forever</td><td>None from updates, but you also get no security fixes until you change it</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Traefik follows semantic versioning: the first number (major) may break your configuration, the second (minor) adds features, the third (patch) only fixes bugs and security issues. The safer the tag, the more often you have to update it by hand.</p>



<p class="wp-block-paragraph">Why this matters for a reverse proxy in particular: when Traefik breaks, <em>every</em> service is offline at once, not just one. And in many homelabs the updates run automatically at night. With <code>latest</code>, a major release can take everything down at 4 am, and you find out in the morning when nothing loads.</p>



<p class="wp-block-paragraph">My recommendation is <code>v3</code> if your updates run automatically, or <code>v3.7</code> if you update by hand and read the release notes anyway. Traefik is exposed to the internet, so don&#8217;t pin an exact version and then forget it. Security fixes only arrive with new patch releases.</p>



<p class="wp-block-paragraph">Updating and going back are both one change in the compose file:</p>



<pre class="wp-block-code"><code># which version runs right now?
docker exec traefik traefik version

# update: change the tag if needed (e.g. v3.7 -&gt; v3.8), then
docker compose pull traefik
docker compose up -d traefik
docker logs traefik --since 2m    # look for errors

# something broke? put the old tag back and run the same two commands again</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>Tip:</strong> before a minor or major update, read the migration notes in the Traefik documentation (&#8220;Migration&#8221; section). They list exactly which options changed. For patch updates, that&#8217;s not necessary.</p>
</div>



<p class="wp-block-paragraph">Start it with <code>docker compose up -d</code> and check <code>docker logs traefik</code>. There should be no errors, only a line saying the providers started.</p>



<h2 class="wp-block-heading">4. Publishing a container with labels</h2>



<p class="wp-block-paragraph">This is where Traefik shines. A service doesn&#8217;t publish any port on the host. It joins the <code>proxy</code> network and describes its route with labels. Here is my Vaultwarden as an example, reduced to the Traefik part:</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>): any name, but unique across all containers. Copying a block and forgetting to rename it is the most common mistake; the second container then silently replaces the first.</li>



<li><strong><code>rule</code>:</strong> which host name belongs to this service. You can combine rules, for example <code>Host(`example.com`) &amp;&amp; PathPrefix(`/api`)</code>.</li>



<li><strong><code>entrypoints=websecure</code>:</strong> HTTPS only. Plain HTTP never reaches the router, because the entrypoint redirects it first.</li>



<li><strong><code>tls.certresolver=letsencrypt</code>:</strong> Traefik requests the certificate for the host name on the first request and renews it on its own.</li>



<li><strong><code>server.port</code>:</strong> the port <em>inside</em> the container, not one on the host. Traefik usually guesses it from the image, but stating it avoids surprises with images that expose several ports.</li>
</ul>



<p class="wp-block-paragraph">After <code>docker compose up -d</code> the service is online under its name, with a valid certificate. No Traefik restart, no port forwarding, no certificate handling.</p>



<h2 class="wp-block-heading">5. Services outside Docker</h2>



<p class="wp-block-paragraph">My Home Assistant runs in a virtual machine, not in Docker, so it has no labels. For services like this I write the route as a file in the <code>dynamic</code> folder:</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">The address is the VM&#8217;s local IP address and port. Save the file and the route is live; Traefik&#8217;s log shows that it read the new configuration. The same works for a NAS, a router&#8217;s web interface or anything else in your network that speaks HTTP.</p>



<p class="wp-block-paragraph">Home Assistant also needs to know that it sits behind a proxy, or it rejects the requests with &#8220;400 Bad Request&#8221;. In its <code>configuration.yaml</code>:</p>



<pre class="wp-block-code"><code>http:
  use_x_forwarded_for: true
  trusted_proxies:
    - 192.0.2.10   # the Docker host running Traefik</code></pre>



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



<p class="wp-block-paragraph">I use the <strong>TLS challenge</strong>: Let&#8217;s Encrypt connects to port 443, and Traefik proves it controls the host name right there. The advantages: no extra port, no DNS provider API key, and it works for every host name that points to your IP address. The limit: no wildcard certificates, for those you need the DNS challenge.</p>



<p class="wp-block-paragraph">All certificates and the account key end up in <code>acme.json</code>. Back up this file. Without it, Traefik requests every certificate again after a rebuild, and Let&#8217;s Encrypt has rate limits. How I back up the whole homelab is explained in <a href="/homelab-backup-snapshots-dumps-offline-copy/">a separate article</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>Watch the rate limits:</strong> while you are still experimenting, add <code>--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory</code>. The staging certificates aren&#8217;t trusted by browsers, but you can&#8217;t lock yourself out for a week by failing too often. Remove the line and empty <code>acme.json</code> once everything works.</p>
</div>



<h2 class="wp-block-heading">7. The dashboard</h2>



<p class="wp-block-paragraph">Traefik&#8217;s dashboard shows every router, service and middleware it knows, and whether it has errors. It&#8217;s the fastest way to see why a route doesn&#8217;t work. I publish it like any other service, with labels on the Traefik container itself:</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> is Traefik&#8217;s built-in service for the dashboard; there&#8217;s no port to set.</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>Don&#8217;t leave the dashboard open:</strong> as shown here, anyone can see your whole setup. In my homelab it sits behind single sign-on. How that works is part of the <a href="/traefik-hardening-geoblock-crowdsec-sso/">hardening article</a>. Until then, either don&#8217;t publish the dashboard at all or add at least a password (Traefik&#8217;s <code>basicauth</code> middleware).</p>
</div>



<h2 class="wp-block-heading">8. When something doesn&#8217;t work</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>What you see</th><th>Usual cause</th></tr></thead><tbody><tr><td><code>404 page not found</code> from Traefik</td><td>No router matches: typo in the host name, <code>traefik.enable=true</code> missing, or the container isn&#8217;t running</td></tr><tr><td><code>502 Bad Gateway</code></td><td>Router found, but Traefik can&#8217;t reach the service: wrong <code>server.port</code>, or the container isn&#8217;t in the <code>proxy</code> network</td></tr><tr><td><code>504 Gateway Timeout</code></td><td>The service is reachable but too slow, or a firewall drops the connection (typical for VMs)</td></tr><tr><td>Certificate &#8220;TRAEFIK DEFAULT CERT&#8221;</td><td>Let&#8217;s Encrypt failed: port 443 not reachable from outside, DNS not pointing to you yet, or a rate limit. The reason is in <code>docker logs traefik</code></td></tr><tr><td>A service shows another service&#8217;s page</td><td>Two containers use the same router name</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Two commands cover most cases:</p>



<pre class="wp-block-code"><code># errors while reading routes or getting certificates
docker logs traefik 2&gt;&amp;1 | grep -i -E "error|acme"

# did the request reach Traefik, and what did it answer?
tail -f traefik-logs/access.log</code></pre>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li><strong>Labels beat config files</strong> for containers: the route lives next to the service it belongs to, and it disappears when the service does.</li>



<li><strong><code>exposedbydefault=false</code> is not optional.</strong> Publishing should always be a deliberate decision per container.</li>



<li><strong>One shared network</strong> and <code>traefik.docker.network=proxy</code> on every service saved me from most 502 errors.</li>



<li><strong>Back up <code>acme.json</code></strong> together with the compose files.</li>



<li><strong>A reverse proxy is not a firewall.</strong> It decides <em>where</em> a request goes, not <em>whether</em> it should get in. That&#8217;s the topic of the <a href="/traefik-hardening-geoblock-crowdsec-sso/">next article</a>: geo-blocking, CrowdSec and single sign-on in front of Traefik&#8217;s routes.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>The paperless office: a practical guide with Paperless-ngx, GoBD and retention periods</title>
		<link>https://selfhostjournal.eu/paperless-office-guide/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Thu, 22 Oct 2026 20:26:49 +0000</pubDate>
				<category><![CDATA[Experience]]></category>
		<category><![CDATA[Homelab]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=102</guid>

					<description><![CDATA[How to go paperless in a small German office: Paperless-ngx setup, e-invoices, daily routines, GoBD process documentation and the current retention periods.]]></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">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Paper piles up, invoices arrive as e-mail, PDF and now XML, and German law expects you to keep all of it for up to ten years, findable and unchanged.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> A self-hosted document archive (Paperless-ngx), a network scanner, a few clear daily routines, a written process description, and a retention plan based on the current German rules.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Paperless-ngx in Docker on a small home server, a network scanner with document feeder, Nextcloud as file share, legal status in Germany as of October 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>Not tax or legal advice:</strong> this guide describes how we set things up for a small German business and what the rules say as far as I understand them. Retention periods and requirements change. Check your own setup with your tax advisor before you destroy any paper original.</p>
</div>



<p class="wp-block-paragraph">Small businesses and home offices drown in paper: letters, receipts, bank statements, contracts. On top of that, more and more invoices arrive by e-mail, and since 2025 every business in Germany must be able to receive electronic invoices. That is a good moment to stop filing paper and build a proper paperless office. Mine runs in my homelab, and the examples below come from that setup. This guide covers the whole path: the technical setup, the daily routines for the people who work with it, the compliance side, and how long you really have to keep what.</p>



<h2 class="wp-block-heading">1. What &#8220;paperless&#8221; should mean</h2>



<p class="wp-block-paragraph">Before buying a scanner, agree on the goal. For us it was four rules:</p>



<ol class="wp-block-list">
<li><strong>One archive</strong> for everything: scanned paper, e-mail attachments and e-invoices end up in the same place.</li>



<li><strong>Digital originals stay original.</strong> A PDF or XML invoice that arrived by e-mail is stored exactly as received. Printing it and scanning it again is not allowed.</li>



<li><strong>Everything is searchable</strong> through text recognition (OCR) and a small set of tags.</li>



<li><strong>Nothing is lost:</strong> automatic backups, including one copy outside the house.</li>
</ol>



<h2 class="wp-block-heading">2. The technical setup</h2>



<p class="wp-block-paragraph">The building blocks are cheap and mostly open source:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Part</th><th>What we use</th><th>Why</th></tr></thead><tbody><tr><td>Scanner</td><td>Network multifunction printer with document feeder and duplex</td><td>Scans a whole stack of letters in one go, straight into the network</td></tr><tr><td>Scan front end</td><td>scanservjs (web interface for the scanner)</td><td>Anyone in the house can scan from the browser, no driver on each PC</td></tr><tr><td>Archive</td><td>Paperless-ngx in Docker</td><td>OCR, tags, full-text search, keeps the original file, multi-user</td></tr><tr><td>Database</td><td>PostgreSQL</td><td>Reliable, easy to back up with a dump</td></tr><tr><td>File share</td><td>Nextcloud</td><td>A readable folder structure for people who don&#8217;t use Paperless directly, and for the tax advisor</td></tr><tr><td>Login</td><td>Single sign-on in front of everything</td><td>One login, no shared passwords</td></tr><tr><td>Backup</td><td>Nightly snapshots plus an encrypted offsite copy</td><td>Ten years of documents must survive a dead disk</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">A minimal Paperless-ngx stack looks like this. Passwords and keys go into a <code>.env</code> file next to it, never into the compose file:</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"   # only via 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">A few settings matter more than they look:</p>



<ul class="wp-block-list">
<li><strong>OCR language:</strong> <code>deu+eng</code> recognises German umlauts and English words in the same document.</li>



<li><strong>Original and archive version:</strong> Paperless keeps the file exactly as it arrived and creates a separate searchable PDF/A next to it. For compliance, the original is the one that counts.</li>



<li><strong>Consume folder:</strong> the scanner, the e-mail import and people drop files into one folder. Paperless picks them up from there.</li>



<li><strong>Not on the internet:</strong> the port is bound to the server itself and only reachable through a reverse proxy with single sign-on. An archive full of invoices, contracts and bank statements is a prize for attackers.</li>
</ul>



<h3 class="wp-block-heading">E-invoices need special care</h3>



<p class="wp-block-paragraph">Since 1 January 2025 every business in Germany must be able to <strong>receive</strong> electronic invoices from other German businesses. An e-invoice is a structured file: either an XRechnung (pure XML) or a ZUGFeRD invoice (a PDF with the XML embedded). Two things follow from that:</p>



<ul class="wp-block-list">
<li>The <strong>XML is the legal original</strong>, not a printout or a screenshot. Store the file exactly as received.</li>



<li>A pure XML file is not readable for humans. Keep a viewer at hand (for example a free XRechnung viewer) and store a readable visualisation <em>next to</em> the original, never instead of it.</li>
</ul>



<p class="wp-block-paragraph">For <strong>sending</strong> there are transition periods: paper and simple PDF invoices are still allowed until the end of 2026, and until the end of 2027 for businesses with a prior-year turnover of up to 800,000 euros. After that, invoices between German businesses must be e-invoices. Small amounts up to 250 euros and businesses under the small-business rule have exceptions; ask your tax advisor which apply to you.</p>



<h2 class="wp-block-heading">3. Classification: tags, correspondents and a bit of AI</h2>



<p class="wp-block-paragraph">Search only works if documents are sorted consistently. Paperless offers four tools. Use them for different things:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Field</th><th>Use it for</th><th>Example</th></tr></thead><tbody><tr><td>Correspondent</td><td>Who sent it</td><td>Telecom provider, insurance, tax office</td></tr><tr><td>Document type</td><td>What it is</td><td>Invoice, contract, bank statement, letter</td></tr><tr><td>Tags</td><td>Status and purpose</td><td><code>business</code>, <code>to-review</code>, <code>for-tax-advisor</code></td></tr><tr><td>Custom fields</td><td>Structured data</td><td>Amount, target folder, &#8220;keep until&#8221; year</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Paperless learns from your sorting and suggests correspondents and tags for new documents. We added a small classifier on top that asks a language model for the right target folder. If you do the same, be careful with privacy: our classifier only sends the first part of the text, and it <strong>masks personal data before sending it</strong>: names, addresses, IBANs, tax and customer numbers, and all money amounts. Documents it is unsure about get a <code>to-review</code> tag instead of being filed automatically.</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>Tip:</strong> keep the list of tags and folders short and write it down. Twenty well-defined folders beat a hundred that everyone interprets differently.</p>
</div>



<h2 class="wp-block-heading">4. Workflows for the people who use it</h2>



<p class="wp-block-paragraph">The technology is the easy part. The paperless office only works if everyone follows the same routines. These are routines that work well for a small office; adapt them to your situation:</p>



<h3 class="wp-block-heading">Incoming paper (daily)</h3>



<ol class="wp-block-list">
<li>Open the mail, remove staples, put everything into the document feeder.</li>



<li>Scan with duplex at 300 dpi. One document per scan job, or use separator sheets.</li>



<li>Check the result in Paperless the same day: is every page readable, is the OCR text plausible, did the classification work?</li>



<li>Put the paper into a box for the current month. It stays there until the monthly check (see below), not longer.</li>
</ol>



<h3 class="wp-block-heading">Invoices and documents by e-mail</h3>



<ul class="wp-block-list">
<li>Use one dedicated mailbox for invoices and give that address to all suppliers.</li>



<li>Let Paperless fetch the attachments automatically with its built-in mail import and tag them for review.</li>



<li>Never print an e-mail invoice. The file is the original.</li>
</ul>



<h3 class="wp-block-heading">Receipts on the road</h3>



<p class="wp-block-paragraph">Fuel, parking, office supplies: photograph the receipt with the phone the same day and upload it, for example through a Paperless mobile app or a shared Nextcloud folder that Paperless consumes. Keep the paper receipt in the month&#8217;s box as well, because thermal paper fades and a bad photo is worthless.</p>



<h3 class="wp-block-heading">Monthly routine</h3>



<ol class="wp-block-list">
<li>Work through everything tagged <code>to-review</code>.</li>



<li>Compare the month&#8217;s bank statements with the filed invoices: is there a document for every booking?</li>



<li>Hand the month over to the tax advisor (a shared folder or an export with all <code>for-tax-advisor</code> documents).</li>



<li>Check a sample of the scanned paper against the originals, note the check in the log, then destroy the paper. Keep the exceptions listed in section 6.</li>
</ol>



<h3 class="wp-block-heading">Roles and permissions</h3>



<p class="wp-block-paragraph">Give every person their own login. Business documents belong to the business owner; private household documents can be visible to the whole family. Nobody except the administrator may delete documents, and even then deletion should go through a trash bin with a waiting period.</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>Watch out:</strong> in Paperless, tags, correspondents and document types created with the administrator&#8217;s account belong to the administrator. Other users then can&#8217;t see them, and their uploads stay untagged. Create shared objects without an owner, or give them explicit permissions.</p>
</div>



<h2 class="wp-block-heading">5. Compliance: GoBD, process documentation and data protection</h2>



<p class="wp-block-paragraph">In Germany, digital bookkeeping and archiving follow the GoBD, the tax authority&#8217;s principles for proper electronic records. In practice they boil down to a few requirements:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Requirement</th><th>What it means</th><th>How we cover it</th></tr></thead><tbody><tr><td>Completeness</td><td>Every relevant document is captured</td><td>Monthly comparison of bank statements and invoices</td></tr><tr><td>Immutability</td><td>Originals can&#8217;t be changed unnoticed</td><td>Paperless stores the original file with a checksum; changes are only made to metadata; nightly snapshots</td></tr><tr><td>Traceability</td><td>It is clear who did what and when</td><td>Personal logins, Paperless audit log, scan log</td></tr><tr><td>Availability</td><td>Documents can be shown and evaluated during the whole retention period</td><td>Backups, offsite copy, export function, open formats (PDF/A, XML)</td></tr><tr><td>Machine readability</td><td>Digital originals are kept in their original format</td><td>E-invoices and PDFs are stored as received</td></tr><tr><td>Documented process</td><td>A written process description exists</td><td>See below</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Be honest about one point: a self-hosted Paperless-ngx is not a certified &#8220;audit-proof&#8221; archive. You cover the requirements through the way you run it: restricted deletion rights, logging, backups and a documented process. Discuss with your tax advisor whether that is enough for your business.</p>



<h3 class="wp-block-heading">The process documentation (Verfahrensdokumentation)</h3>



<p class="wp-block-paragraph">This document is what makes destroying paper after scanning possible in the first place, so write it <em>before</em> you shred the first original. It describes in plain words:</p>



<ul class="wp-block-list">
<li>which documents are scanned, by whom, when and with which device and settings;</li>



<li>how the scan is checked for completeness and legibility, and who checks it;</li>



<li>how e-mail and e-invoices are received, checked and archived;</li>



<li>when and how paper originals are destroyed, and which ones are kept;</li>



<li>the system: software, versions, storage, access rights, backups and how a restore works;</li>



<li>who is responsible, and when the document was last reviewed.</li>
</ul>



<p class="wp-block-paragraph">Review it once a year and every time something changes, such as a new scanner, a new software version or a new person doing the bookkeeping. The process documentation itself must be kept as long as the documents it describes.</p>



<h3 class="wp-block-heading">Data protection (GDPR)</h3>



<ul class="wp-block-list">
<li>The archive contains personal data of customers, suppliers and family members. Limit access to the people who need it.</li>



<li>Every external service that sees documents (cloud storage, an AI classifier, an OCR service) needs a data processing agreement, and personal data should be masked before it leaves the house.</li>



<li>Retention periods are also deletion deadlines: once a document is no longer needed for tax or commercial law, personal data in it should be deleted.</li>
</ul>



<h2 class="wp-block-heading">6. How long to keep what</h2>



<p class="wp-block-paragraph">The tax code (§ 147 AO) and the commercial code (§ 257 HGB) set the periods. Since 2025 the period for booking vouchers is shorter than before:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Period</th><th>Documents</th></tr></thead><tbody><tr><td><strong>10 years</strong></td><td>Books and records, inventories, opening balance sheets, annual financial statements, management reports, and the work instructions and organisational documents needed to understand them (including the process documentation)</td></tr><tr><td><strong>8 years</strong></td><td>Booking vouchers: incoming and outgoing invoices, receipts, bank statements, payroll documents, credit notes (shortened from 10 years; applies to all vouchers whose period had not yet expired at the start of 2025)</td></tr><tr><td><strong>6 years</strong></td><td>Business letters received and sent (including e-mails that work like letters, such as orders and confirmations), and other documents that matter for taxes</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Three details people often get wrong:</p>



<ul class="wp-block-list">
<li><strong>The period starts at the end of the calendar year</strong> in which the document was created or received. An invoice from March 2026 must be kept until the end of 2034 (8 years from 31 December 2026).</li>



<li><strong>Open tax assessments extend it:</strong> as long as the tax assessment for a year is still open (for example during an appeal or an audit), the documents for it must not be destroyed, even if the period has passed.</li>



<li><strong>Some industries differ:</strong> for banks, insurers and investment firms, other periods apply. Check if that concerns you.</li>
</ul>



<p class="wp-block-paragraph">Some paper originals should never be destroyed after scanning, even with a good process: notarial deeds, annual financial statements and opening balance sheets with original signatures, customs documents, and anything where the original itself has legal value (for example certificates or documents with seals). Keep these in a folder, and scan them for convenience only.</p>



<p class="wp-block-paragraph">A simple way to handle this in Paperless: a custom field &#8220;keep until&#8221; (a year) on every document type that has a retention period, and a saved view that lists everything whose year has passed. Once a year you decide what to delete. Don&#8217;t let anything be deleted automatically.</p>



<h2 class="wp-block-heading">7. Rollout plan</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Phase</th><th>Duration</th><th>What happens</th></tr></thead><tbody><tr><td>1. Plan</td><td>1 week</td><td>Agree on goals, folder list, tags and roles; talk to the tax advisor</td></tr><tr><td>2. Build</td><td>1–2 weeks</td><td>Set up server, Paperless, scanner, mail import, backups; test a restore</td></tr><tr><td>3. Pilot</td><td>1 month</td><td>Scan everything new, keep all paper, fix the classification and the routines</td></tr><tr><td>4. Document</td><td>during the pilot</td><td>Write the process documentation, have it checked</td></tr><tr><td>5. Go live</td><td>from month 2</td><td>Destroy paper after the monthly check (except the exceptions)</td></tr><tr><td>6. Old files</td><td>as time allows</td><td>Scan old folders from the newest year backwards; anything past its period can go directly</td></tr></tbody></table></figure>



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



<ul class="wp-block-list">
<li>Archive only reachable through login, ideally single sign-on, not exposed to the internet</li>



<li>Nightly backup, offsite copy, restore tested at least once</li>



<li>Dedicated invoice mailbox with automatic import</li>



<li>E-invoices stored as original XML or ZUGFeRD PDF, with a viewer available</li>



<li>Written folder and tag list</li>



<li>Process documentation written and reviewed by the tax advisor</li>



<li>Monthly review routine with a scan check and log</li>



<li>Retention field and a yearly deletion review</li>



<li>Box for paper originals that must be kept</li>
</ul>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li>The hard part is not the software but the routine. Plan a fixed time each day and each month for it.</li>



<li>Write the process documentation early. It forces you to decide things you would otherwise leave open.</li>



<li>Mask personal data before any document text leaves the house, also for &#8220;just a quick AI classification&#8221;.</li>



<li>Check ownership and permissions with a second user account before you go live. Many &#8220;it doesn&#8217;t work for her&#8221; problems were permission problems.</li>



<li>Deleting in one place should delete everywhere. We sync the Paperless trash to the Nextcloud copy, otherwise old copies live on.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Heating automations in Home Assistant: per-room logic, TRV calibration and the issues we hit</title>
		<link>https://selfhostjournal.eu/home-assistant-heating-trv-calibration/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Thu, 22 Oct 2026 19:18:57 +0000</pubDate>
				<category><![CDATA[Home Assistant]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=91</guid>

					<description><![CDATA[Twelve SONOFF TRVZB valves in nine rooms: Toggle, Target Sync and Calibration per room, a door and window pause, safety nets, and the problems that taught us each of them.]]></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">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Radiator thermostats measure the temperature right next to the hot radiator, a shared blueprint stopped reacting, and an update silently broke the heating.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> Three small automations per room (Toggle, Target Sync, Calibration), a real room sensor fed into every valve, and a few safety nets that tell me when something drifts.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Home Assistant, Zigbee2MQTT 2.14, 12 SONOFF TRVZB radiator valves, Zigbee temperature and motion sensors</p>
</div>



<p class="wp-block-paragraph">Our house has nine heated rooms with twelve smart radiator valves. The goal was simple: warm when someone is in the room, a few degrees less when not, and no heating with the window open. Getting there took a few rebuilds. This is the setup that works now, and the problems we hit on the way.</p>



<h2 class="wp-block-heading">The building blocks per room</h2>



<p class="wp-block-paragraph">Every room uses the same set of helpers. That makes the automations almost identical, and a new room is mostly copy and rename.</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Helper</th><th>Purpose</th></tr></thead><tbody><tr><td><code>input_boolean.heating_office_on</code></td><td>Master switch for the room (dashboard, Telegram, &#8220;all on/off&#8221; scripts)</td></tr><tr><td><code>input_number.temperature_comfort_office</code></td><td>Comfort temperature, for example 21 °C</td></tr><tr><td><code>input_number.temperature_eco_office</code></td><td>ECO temperature, for example 19 °C</td></tr><tr><td><code>schedule.schedule_heating_office</code></td><td>When the room should be heated at all</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">On top of that, each room has three automations:</p>



<ul class="wp-block-list">
<li><strong>Toggle</strong> follows the master switch and turns the valve and the Target Sync on or off.</li>



<li><strong>Target Sync</strong> decides between Comfort and ECO and writes the target to the valve.</li>



<li><strong>Calibration</strong> feeds the real room temperature into the valve.</li>
</ul>



<h2 class="wp-block-heading">Why I dropped the blueprint</h2>



<p class="wp-block-paragraph">At first all rooms ran on one shared heating blueprint. It was convenient until it wasn&#8217;t: at the end of August it simply stopped reacting to some inputs, because an internal guard condition failed. One bug in a shared blueprint means every room is affected at once, and the logic is hard to debug from the outside.</p>



<p class="wp-block-paragraph">I replaced it with three plain automations per room. That is more automations (33 for the whole house), but each one is short, does one thing, and shows up clearly in the trace when something goes wrong.</p>



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



<p class="wp-block-paragraph">When the master switch goes on, the valve is set to <code>heat</code>, the Target Sync is enabled and run once, so the right temperature is applied immediately. When it goes off, the Target Sync is disabled first, then the valve is switched off.</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">The Target Sync is where the rooms differ. In the office it follows a motion sensor: Comfort after two minutes of motion, ECO after ten minutes without. The delays stop the heating from jumping up and down when someone just walks through.</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">Other rooms use other signals with the same pattern:</p>



<ul class="wp-block-list">
<li><strong>Schedule only</strong> (kitchen, bedroom, bathroom): Comfort inside the schedule, ECO outside it.</li>



<li><strong>Schedule and presence</strong> (rooms that belong to one person): Comfort only if the schedule is on <em>and</em> that person is at home.</li>



<li><strong>A device as presence</strong>: in the gaming room the AV receiver is the best sign that someone is there. Receiver on means Comfort, receiver off means a lower setback.</li>
</ul>



<h2 class="wp-block-heading">3. Calibration: the part that matters most</h2>



<p class="wp-block-paragraph">A radiator thermostat measures the temperature where it sits: right next to the hot radiator. So it thinks the room is warm long before it is, and closes too early.</p>



<p class="wp-block-paragraph">The SONOFF TRVZB can use an external temperature instead. In Zigbee2MQTT it has two relevant entities:</p>



<ul class="wp-block-list">
<li>a <strong>select</strong> &#8220;Temperature sensor&#8221;: <code>local_temperature</code> (its own sensor) or <code>remote_temperature</code> (an external value)</li>



<li>a <strong>number</strong> &#8220;External temperature input&#8221; that receives the external value</li>
</ul>



<p class="wp-block-paragraph">The Calibration automation copies a real room sensor into that input whenever the reading has been stable for two minutes. It also checks the select, because after a reconnect or a battery change the valve sometimes falls back to its own sensor.</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:
  # switch the valve back to the external sensor if it reverted
  - 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
  # feed the real room temperature into the valve
  - 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>Tip:</strong> leave <code>remote_source_offline</code> alone. It is the valve&#8217;s own fallback when the external value goes stale. If you force it back to <code>remote_temperature</code>, the valve heats with an old value.</p>
</div>



<p class="wp-block-paragraph">The condition <code>above: 0</code> keeps a sensor that reports 0 or nothing from cooling the house down. With the calibration in place, the room finally reaches the temperature it shows on the dashboard.</p>



<h2 class="wp-block-heading">4. No heating with the door open</h2>



<p class="wp-block-paragraph">The living room has a terrace door and two windows, and the heating kept running when they were open. The pause automation switches the three valves off after a door or window has been open for one minute, and switches them back on when <em>all</em> of them are closed again.</p>



<p class="wp-block-paragraph">The important detail: it does <strong>not</strong> touch the master switch. Instead it sets its own flag (<code>input_boolean.heating_living_room_paused</code>). Toggle and Target Sync leave the valves alone while the flag is on. Because the flag is a helper and not a wait in the automation, it also survives a Home Assistant restart. A start trigger restores the heating if everything was closed while Home Assistant was down.</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:   # opened while heating is on -> pause
          - 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:   # all closed and paused by us -> resume
          - 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. Safety nets</h2>



<p class="wp-block-paragraph">Automations fail quietly. These three make sure I hear about it:</p>



<ul class="wp-block-list">
<li><strong>Child lock enforcement:</strong> after a battery change or a re-pair, a valve&#8217;s child lock can be off, and then anyone can turn the knob. If any of the twelve locks is off for 10 seconds, or Home Assistant starts, all locks are switched on again.</li>



<li><strong>Target mismatch alert:</strong> 10 minutes after a valve&#8217;s target or a Comfort/ECO value changes, a template checks every valve. If a target matches neither its room&#8217;s ECO nor its Comfort value (tolerance 0.25 °C), I get a Telegram message. Rooms with heating off, valves that are off and the paused living room are skipped. This catches a Target Sync that didn&#8217;t reach the valve.</li>



<li><strong>Zigbee2MQTT version alert:</strong> a message whenever Zigbee2MQTT runs a new version. The reason is the next section.</li>
</ul>



<p class="wp-block-paragraph">The core of the mismatch check, simplified to one valve:</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">The issues we had</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Problem</th><th>Cause</th><th>Fix</th></tr></thead><tbody><tr><td>Rooms didn&#8217;t react to schedule or motion changes</td><td>The shared heating blueprint&#8217;s internal guard condition failed</td><td>Three own automations per room instead of the blueprint</td></tr><tr><td>Rooms stayed colder than the target</td><td>The valve measured next to the radiator</td><td>Calibration with a real room sensor</td></tr><tr><td>Valve went back to its own sensor</td><td>Reconnect or battery change resets the select</td><td>Calibration also triggers on the select and switches it back</td></tr><tr><td>Heating ran with the terrace door open</td><td>No pause logic</td><td>Door and window pause with its own flag</td></tr><tr><td>Calibration silently stopped working</td><td>An automatic update from Zigbee2MQTT 2.14.1 to 2.14.2 renamed the TRVZB sensor options</td><td>Automations switched to the new option names, plus a version change alert</td></tr><tr><td>Valves could be changed by hand</td><td>Child lock off after battery change or re-pair</td><td>Child lock enforcement</td></tr><tr><td>A valve sat at a wrong target unnoticed</td><td>A Target Sync that didn&#8217;t reach the valve</td><td>Target mismatch alert</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>Watch out:</strong> if Zigbee2MQTT runs on the <code>latest</code> image and updates itself, a converter change can rename entities or options overnight. Nothing errors, the heating just quietly stops doing what you think it does. Either pin the version and update on purpose, or at least get notified when the version changes.</p>
</div>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li>Small, boring automations per room beat one clever shared blueprint. They are easier to read, test and fix.</li>



<li>A radiator thermostat without an external sensor heats to the wrong temperature. Feeding it a real room sensor is worth the extra automation.</li>



<li>Use a flag helper instead of a wait for anything that must survive a restart.</li>



<li>Every automation that can fail silently needs an alert that checks the result, not just the trigger.</li>
</ul>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Single sign-on for WordPress with Authentik</title>
		<link>https://selfhostjournal.eu/wordpress-sso-authentik/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Thu, 22 Oct 2026 16:53:17 +0000</pubDate>
				<category><![CDATA[Security and SSO]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=29</guid>

					<description><![CDATA[Every setting on both sides: the Authentik provider and application, all values for the OpenID Connect Generic plugin, a curl test and a troubleshooting table.]]></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">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> WordPress was the last service still asking for its own password.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> An OAuth2/OpenID provider in Authentik plus the OpenID Connect Generic plugin in WordPress, with every value listed below.</p>



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



<p class="wp-block-paragraph">I use Authentik as the single login for my self-hosted services. This blog should work the same way: one click on the login button, passkey, done. The normal WordPress login stays as a fallback, in case Authentik is ever down.</p>



<p class="wp-block-paragraph">In the examples, Authentik runs at <code>https://auth.example.com</code>, the blog at <code>https://blog.example.com</code>, and the Authentik application slug is <code>blog</code>. Replace these with your own values.</p>



<h2 class="wp-block-heading">How the login works</h2>



<ol class="wp-block-list">
<li>You click the login button on the WordPress login page.</li>



<li>WordPress sends you to Authentik, where you log in, for example with a passkey.</li>



<li>Authentik sends you back to WordPress with a one-time code.</li>



<li>WordPress exchanges the code for tokens in the background, reads your username and e-mail, and logs you in as the matching WordPress user.</li>
</ol>



<h2 class="wp-block-heading">1. Authentik: create the provider</h2>



<p class="wp-block-paragraph">In the Authentik admin interface go to <strong>Applications → Providers → Create</strong> and choose <strong>OAuth2/OpenID Provider</strong>. These are the settings I use:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Setting</th><th>Value</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> (no extra &#8220;Allow access?&#8221; screen)</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>generated by Authentik, copy both for WordPress</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>the default mappings for <code>openid</code>, <code>email</code> and <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>on</td></tr><tr><td>Issuer mode</td><td>Each provider has a different issuer</td></tr><tr><td>Grant types</td><td>Authorization code and refresh token</td></tr><tr><td>Access token validity</td><td>5 minutes</td></tr><tr><td>Refresh token validity</td><td>30 days</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>Watch out:</strong> if you create the provider through the Authentik API instead of the UI, set <code>grant_types</code> yourself. Without it the list stays empty and every login ends with &#8220;The request is otherwise malformed&#8221;. The Authentik server log then shows &#8220;Invalid grant_type for provider&#8221;.</p>
</div>



<p class="wp-block-paragraph">For reference, the same provider created through the API:</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 of default-provider-authorization-implicit-consent&gt;",
    "invalidation_flow": "&lt;uuid of default-provider-invalidation-flow&gt;",
    "property_mappings": ["&lt;openid uuid&gt;", "&lt;email uuid&gt;", "&lt;profile uuid&gt;"],
    "signing_key": "&lt;uuid of the signing certificate&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: create the application</h2>



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



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Setting</th><th>Value</th></tr></thead><tbody><tr><td>Name</td><td>Blog</td></tr><tr><td>Slug</td><td><code>blog</code> (it becomes part of the issuer and JWKS URLs)</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">Then open the application, go to <strong>Policy / Group / User Bindings</strong> and bind the users or the group who may log in to WordPress. Without a binding, every Authentik user can use the application.</p>



<p class="wp-block-paragraph">All endpoints are listed in the discovery document of the application. Open it once to check that it works:</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: install the plugin</h2>



<p class="wp-block-paragraph">Install and activate <strong>OpenID Connect Generic</strong> (by daggerhart) from the plugin directory. The settings are under <strong>Settings → OpenID Connect Client</strong>. As soon as the plugin is active, the login page shows a button. It only works once the settings below are saved.</p>



<h2 class="wp-block-heading">4. WordPress: all plugin settings</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody><tr><td>Login Type</td><td>OpenID Connect button on login form</td></tr><tr><td>Client ID</td><td>from the Authentik provider</td></tr><tr><td>Client Secret Key</td><td>from the 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 (if your version asks)</td><td><code>https://auth.example.com/application/o/blog/</code></td></tr><tr><td>ACR values</td><td>leave empty</td></tr><tr><td>Identity Key</td><td><code>preferred_username</code></td></tr><tr><td>Disable SSL Verify</td><td>off</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>off</td></tr><tr><td>Enable Refresh Token</td><td>on</td></tr><tr><td>Link Existing Users</td><td>on</td></tr><tr><td>Create user if does not exist</td><td>off</td></tr><tr><td>Redirect Back to Origin Page</td><td>on</td></tr><tr><td>Redirect to the login screen when session is expired</td><td>off</td></tr><tr><td>Enable Logging</td><td>on while testing, off afterwards</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Only the JWKS URI, the End Session URL and the issuer contain the application slug. The authorize, token and userinfo endpoints are the same for every application in Authentik.</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>Tip:</strong> the bottom of the plugin settings page shows the exact Redirect URI WordPress will use. Copy it from there into Authentik, so the two match character for character.</p>
</div>



<h3 class="wp-block-heading">How existing users are matched</h3>



<p class="wp-block-paragraph">With <em>Link Existing Users</em> on, the plugin looks for a WordPress user whose username matches the value of the identity key, here the Authentik username (<code>preferred_username</code>). The first login links the two accounts, and after that the link is fixed. Use the same username in WordPress and Authentik, or log in once with the matching account. With <em>Create user if does not exist</em> off, nobody gets a new WordPress account just because they have an Authentik login.</p>



<h3 class="wp-block-heading">Optional: keep the secret out of the database</h3>



<p class="wp-block-paragraph">Instead of typing the values into the settings page, you can define them in <code>wp-config.php</code>. The plugin then uses these values and shows the fields as read-only:</p>



<pre class="wp-block-code"><code>define( 'OIDC_CLIENT_ID', 'your-client-id' );
define( 'OIDC_CLIENT_SECRET', 'your-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. Test it</h2>



<p class="wp-block-paragraph">Before clicking the button, test the authorize request from the command line. A working provider redirects to the Authentik login flow:</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=YOUR_CLIENT_ID&amp;state=test&amp;redirect_uri=https%3A%2F%2Fblog.example.com%2Fwp-admin%2Fadmin-ajax.php%3Faction%3Dopenid-connect-authorize"

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



<p class="wp-block-paragraph">If that looks good, log out of WordPress, click the login button and log in with Authentik. You should land in the dashboard as your existing user.</p>



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



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>What you see</th><th>Likely cause</th></tr></thead><tbody><tr><td>&#8220;The request is otherwise malformed&#8221;</td><td>No grant types on the provider (created through the API)</td></tr><tr><td>&#8220;Redirect URI Error&#8221;</td><td>The redirect URI in Authentik differs from the one WordPress sends, even by a slash</td></tr><tr><td>Back on WordPress, but not logged in</td><td>No WordPress user matches <code>preferred_username</code>, and user creation is off</td></tr><tr><td>&#8220;Permission denied&#8221; in Authentik</td><td>Your user is not bound to the application</td></tr><tr><td>A blank page with &#8220;0&#8221;</td><td>Authentik returned an error to the redirect URI. Check the Authentik server log.</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li>The Authentik UI fills in defaults that the API does not.</li>



<li>The server log tells you the real reason when the browser only shows a generic error.</li>



<li>Keep the local WordPress login as a fallback. If the identity provider is down, you still want to reach your own site.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
