<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Matomo on Kabelbruch</title><link>https://kabelbruch.de/tags/matomo/</link><description>Recent content in Matomo on Kabelbruch</description><image><title>Kabelbruch</title><url>https://kabelbruch.de/og-image.png</url><link>https://kabelbruch.de/og-image.png</link></image><generator>Hugo</generator><language>de-DE</language><lastBuildDate>Sat, 31 Oct 2026 11:30:00 +0100</lastBuildDate><atom:link href="https://kabelbruch.de/tags/matomo/index.xml" rel="self" type="application/rss+xml"/><item><title>Besucherstatistik ohne Cookie-Banner: Matomo im Homelab, gefüttert mit Server-Logs</title><link>https://kabelbruch.de/posts/besucherstatistik-ohne-cookie-banner-matomo-log-import/</link><pubDate>Sat, 31 Oct 2026 11:30:00 +0100</pubDate><guid>https://kabelbruch.de/posts/besucherstatistik-ohne-cookie-banner-matomo-log-import/</guid><description>Wie viele lesen eigentlich mit? Matomo im Homelab, gefüttert mit gekürzten Server-Logs vom VPS – ohne Tracking-Skript, ohne Cookies, ohne Banner.</description><content:encoded><![CDATA[<p>Kaum war die <a href="/series/wie-kabelbruch-entstanden-ist/">Serie über den Aufbau dieser Seite</a> online, kam die naheliegende Frage: Liest das eigentlich jemand? Und wenn ja – was, und woher kommen die Leute?</p>
<p>Die übliche Antwort wäre ein Tracking-Skript in jeder Seite. Damit hätte ich mir aber genau das eingehandelt, was ich hier vermeiden wollte: ein Cookie-Banner, eine aufgeweichte Content-Security-Policy und im schlimmsten Fall die Daten meiner Leser bei einem fremden Dienstleister. Also habe ich nach einem anderen Weg gesucht. Herausgekommen ist eine Besucherstatistik, die ohne ein einziges Byte JavaScript im Browser auskommt.</p>
<h2 id="die-ausgangslage--und-eine-kurze-rechtsfrage">Die Ausgangslage – und eine kurze Rechtsfrage</h2>
<p>Ich bin kein Anwalt, aber eine Regel ist für Besucherstatistiken in Deutschland zentral: <strong>§ 25 TDDDG</strong> (das Gesetz hieß bis 2024 TTDSG). Sobald ein Werkzeug etwas auf dem Gerät des Besuchers speichert oder von dort ausliest – Cookies, aber auch Bildschirmgröße und Ähnliches per JavaScript –, braucht es grundsätzlich eine <strong>Einwilligung</strong>. Daher kommen die Banner.</p>
<p>Eine Auswertung, die <strong>nur auf dem Server</strong> passiert und ausschließlich nutzt, was der Browser ohnehin mitschickt, greift dagegen gar nicht auf das Gerät zu. Matomo schreibt selbst, dass seine <strong>Log-Analytics</strong>-Variante ohne Einwilligung genutzt werden kann, und eine Aufsichtsbehörde aus Baden-Württemberg sieht reine, lokale Auswertungen von Logdateien ohne Drittanbieter ähnlich. Rechtsgrundlage ist dann das berechtigte Interesse nach Art. 6 Abs. 1 lit. f DSGVO.</p>
<p>Damit war die Richtung klar: <strong>Statistik aus Server-Logs.</strong></p>
<h2 id="welches-werkzeug">Welches Werkzeug?</h2>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Matomo (Log-Import)</th>
					<th>Grafana + Loki</th>
					<th>GoAccess</th>
					<th>JavaScript-Tools</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Besuche erkennen, Bots filtern</td>
					<td>✅ eingebaut</td>
					<td>❌ selbst bauen</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Herkunft (Suchmaschine, Social Media)</td>
					<td>✅ automatisch</td>
					<td>⚠️ nur rohe Referrer</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Langzeit-Berichte</td>
					<td>✅</td>
					<td>⚠️ je nach Aufbewahrung</td>
					<td>⚠️</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Kein Banner, keine CSP-Änderung</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
					<td>❌</td>
			</tr>
			<tr>
					<td>Aufwand</td>
					<td>mittel</td>
					<td>mittel</td>
					<td>gering</td>
					<td>gering bis mittel</td>
			</tr>
	</tbody>
</table>
<p><a href="https://matomo.org/" target="_blank" rel="noopener noreferrer">Matomo</a> ist die bekannteste Open-Source-Alternative zu Google Analytics und kann neben dem üblichen JavaScript-Tracker auch Server-Logs importieren. Mein Grafana mit Loki im Homelab hätte die Logs zwar auch gern genommen, aber das ist ein Betriebswerkzeug: Besuche erkennen, Bots aussortieren und Monatsvergleiche hätte ich dort alles selbst nachbauen müssen. GoAccess wäre die schlanke Alternative gewesen. Ich wollte aber etwas mit Google-Analytics-Komfort – also Matomo.</p>
<h2 id="der-aufbau">Der Aufbau</h2>
<p>Der Clou ist, <strong>wo</strong> Matomo läuft. Es läuft nicht auf dem VPS, der die Seite ausliefert, sondern bei mir im Homelab. Und weil ich keine Portfreigabe ins Heimnetz will, holt sich das Homelab die Daten selbst ab:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">VPS: Caddy schreibt Zugriffsprotokoll (IP gekürzt)
</span></span><span class="line"><span class="cl">       │ logrotate, jede Nacht
</span></span><span class="line"><span class="cl">       ▼
</span></span><span class="line"><span class="cl">     /var/log/caddy-export/kabelbruch-access.log-JJJJMMTT
</span></span><span class="line"><span class="cl">       ▲
</span></span><span class="line"><span class="cl">       │ rsync über SSH – nur lesen, nur ausgehend vom Homelab
</span></span><span class="line"><span class="cl">       │
</span></span><span class="line"><span class="cl">Homelab (Docker-Host): Import-Skript ──▶ Matomo (nur intern erreichbar)
</span></span></code></pre></div><p>Beispielwerte in diesem Artikel: <code>example.com</code> ist die Blog-Domain, <code>203.0.113.10</code> der VPS, <code>matomo.home.example.com</code> die interne Adresse von Matomo.</p>
<p><strong>Voraussetzungen:</strong></p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Version / Stand</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>VPS</td>
					<td>Debian 13, Caddy 2 (aus Teil 2 der Serie)</td>
			</tr>
			<tr>
					<td>Homelab</td>
					<td>Docker-Host mit Docker Compose und einem Reverse Proxy (bei mir Nginx Proxy Manager)</td>
			</tr>
			<tr>
					<td>Matomo</td>
					<td>5.14.2 (offizielles Docker-Image <code>matomo:5.14.2-apache</code>)</td>
			</tr>
			<tr>
					<td>Datenbank</td>
					<td>MariaDB 11.4 (LTS)</td>
			</tr>
			<tr>
					<td>Import</td>
					<td><code>import_logs.py</code> aus dem Matomo-Image, ausgeführt in <code>python:3.13-slim</code></td>
			</tr>
	</tbody>
</table>
<h2 id="schritt-1-matomo-im-homelab">Schritt 1: Matomo im Homelab</h2>
<p>Matomo bekommt einen eigenen Compose-Stack. Die Passwörter liegen in einer <code>.env</code>-Datei – und ich erzeuge sie bewusst nur aus Buchstaben und Ziffern, weil Docker Compose ein <code>$</code> im Passwort sonst als Variable interpretiert (darüber bin ich bei einem anderen Dienst schon einmal gestolpert):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">DBPW</span><span class="o">=</span><span class="k">$(</span>openssl rand -base64 <span class="m">32</span> <span class="p">|</span> tr -dc <span class="s1">&#39;A-Za-z0-9&#39;</span> <span class="p">|</span> head -c 32<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ROOTPW</span><span class="o">=</span><span class="k">$(</span>openssl rand -base64 <span class="m">32</span> <span class="p">|</span> tr -dc <span class="s1">&#39;A-Za-z0-9&#39;</span> <span class="p">|</span> head -c 32<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">printf</span> <span class="s2">&#34;MATOMO_DB_PASSWORD=&#39;%s&#39;\nMARIADB_ROOT_PASSWORD=&#39;%s&#39;\n&#34;</span> <span class="s2">&#34;</span><span class="nv">$DBPW</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$ROOTPW</span><span class="s2">&#34;</span> <span class="p">|</span> sudo tee .env &gt; /dev/null
</span></span><span class="line"><span class="cl">sudo chmod <span class="m">600</span> .env
</span></span></code></pre></div><p>Die <code>compose.yaml</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">matomo-db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">mariadb:11.4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">matomo-db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span>--<span class="l">max-allowed-packet=64MB</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">MARIADB_DATABASE=matomo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">MARIADB_USER=matomo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">MARIADB_PASSWORD=${MATOMO_DB_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=Europe/Berlin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./db:/var/lib/mysql</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">matomo</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">matomo:5.14.2-apache</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">matomo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">matomo-db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">MATOMO_DATABASE_HOST=matomo-db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">MATOMO_DATABASE_ADAPTER=mysql</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">MATOMO_DATABASE_TABLES_PREFIX=matomo_</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">MATOMO_DATABASE_USERNAME=matomo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">MATOMO_DATABASE_PASSWORD=${MATOMO_DB_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">MATOMO_DATABASE_DBNAME=matomo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=Europe/Berlin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./matomo:/var/www/html</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">reverse-proxy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">reverse-proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span></code></pre></div><p>Wichtig daran: <strong>Kein Port wird veröffentlicht.</strong> Matomo hängt nur im Netz des Reverse Proxys, die Datenbank nicht einmal dort. Erreichbar ist Matomo ausschließlich intern über <code>https://matomo.home.example.com</code>.</p>
<p>Der Installationsassistent ist schnell durchgeklickt. Zwei Hinweise dazu:</p>
<ul>
<li>Bei der Datenbank fragt Matomo inzwischen nach <strong>MySQL oder MariaDB</strong> – hier MariaDB wählen.</li>
<li>Den angezeigten <strong>Tracking-Code einfach überspringen</strong>. Den brauchen wir nicht.</li>
</ul>
<h3 id="matomo-hinter-dem-reverse-proxy">Matomo hinter dem Reverse Proxy</h3>
<p>Die erste Systemprüfung meldete „Forced SSL Connection“ und einen Tracker-Status 403: Matomo läuft im Container per HTTP und wusste nicht, dass davor jemand HTTPS spricht. Die Lösung steht im Abschnitt <code>[General]</code> der <code>config/config.ini.php</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[General]</span>
</span></span><span class="line"><span class="cl"><span class="na">assume_secure_protocol</span> <span class="o">=</span> <span class="s">1</span>
</span></span><span class="line"><span class="cl"><span class="na">force_ssl</span> <span class="o">=</span> <span class="s">1</span>
</span></span><span class="line"><span class="cl"><span class="na">proxy_host_headers[]</span> <span class="o">=</span> <span class="s">HTTP_X_FORWARDED_HOST</span>
</span></span><span class="line"><span class="cl"><span class="na">enable_auto_update</span> <span class="o">=</span> <span class="s">0</span>
</span></span></code></pre></div><p>Vorher lohnt ein <code>grep</code>: Der Installer hatte <code>proxy_client_headers[]</code> und <code>proxy_scheme_headers[]</code> bereits selbst eingetragen, als er den Proxy erkannt hat. Doppelte Einträge braucht niemand. <code>enable_auto_update = 0</code> schaltet das Update aus der Weboberfläche ab – bei einer Docker-Installation kommt das Update über einen neuen Image-Tag.</p>
<p>Kleine Falle am Rande: Meine Sicherungskopie <code>config.ini.php.bak</code> lag zunächst im selben Ordner. Prompt meldete die Systemprüfung „File integrity: Files were found … we didn&rsquo;t expect them“. Zu Recht – die Kopie liegt dort im Web-Verzeichnis und hat da nichts verloren. Sie wohnt jetzt eine Ebene höher, außerhalb.</p>
<h2 id="schritt-2-datenschutz-einstellungen-in-matomo">Schritt 2: Datenschutz-Einstellungen in Matomo</h2>
<p>Unter <em>Datenschutz → Daten anonymisieren</em>:</p>
<ul>
<li><strong>IP-Adressen anonymisieren: 2 Bytes.</strong> Der Server kürzt schon um ein Byte, Matomo kürzt ein weiteres. Länder lassen sich damit nur noch grob erkennen, Städte gar nicht – für einen Blog völlig egal.</li>
<li><strong>Alte Rohdaten regelmäßig löschen: nach 30 Tagen.</strong> Einzelne Besuche verschwinden, die zusammengefassten Berichte (Aufrufe pro Tag, Monat, Jahr) bleiben.</li>
<li><strong>Alte Berichte löschen: aus.</strong> Die enthalten nichts Personenbeziehbares mehr.</li>
</ul>
<p>Dazu unter <em>Opt-Out für Benutzer</em> „Do Not Track“ unterstützen. Beim Log-Import hat das kaum Wirkung, ist aber ein gutes Signal.</p>
<h2 id="schritt-3-das-zugriffsprotokoll-auf-dem-vps">Schritt 3: Das Zugriffsprotokoll auf dem VPS</h2>
<p>Bisher schrieb Caddy gar kein Zugriffsprotokoll. Jetzt bekommt der Seitenblock im Caddyfile einen <code>log</code>-Abschnitt:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddyfile" data-lang="caddyfile"><span class="line"><span class="cl"><span class="gh">example.com</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">root</span> <span class="nd">*</span> <span class="s">/var/www/kabelbruch</span>
</span></span><span class="line"><span class="cl">    <span class="k">log</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">output</span> <span class="s">file</span> <span class="s">/var/log/caddy/kabelbruch-access.log</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">roll_disabled</span>
</span></span><span class="line"><span class="cl">            <span class="k">mode</span> <span class="mi">0640</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">format</span> <span class="s">filter</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">request&gt;remote_ip</span> <span class="s">ip_mask</span> <span class="mi">24</span> <span class="mi">48</span>
</span></span><span class="line"><span class="cl">            <span class="k">request&gt;client_ip</span> <span class="s">ip_mask</span> <span class="mi">24</span> <span class="mi">48</span>
</span></span><span class="line"><span class="cl">            <span class="k">request&gt;headers&gt;Cookie</span> <span class="s">delete</span>
</span></span><span class="line"><span class="cl">            <span class="k">request&gt;headers&gt;Authorization</span> <span class="s">delete</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span><span class="c1">
</span></span></span><span class="line"><span class="cl"><span class="c1">    # … Rest wie gehabt
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><ul>
<li><strong><code>ip_mask 24 48</code></strong> kürzt IPv4-Adressen auf die ersten drei Blöcke (<code>203.0.113.47</code> wird zu <code>203.0.113.0</code>) und IPv6 auf die ersten 48 Bit – <strong>bevor</strong> irgendetwas auf die Platte geschrieben wird.</li>
<li><strong>Cookie und Authorization</strong> fliegen raus. Die Seite setzt keine Cookies, aber falls ein Browser doch welche mitschickt, landen sie nicht im Protokoll.</li>
<li><strong><code>roll_disabled</code></strong>: Caddy rotiert nicht selbst – das übernimmt gleich <code>logrotate</code>.</li>
</ul>
<h3 id="gescheitert-permission-denied">Gescheitert: <code>permission denied</code></h3>
<p><code>caddy validate</code> meldete „Valid configuration“, der anschließende Reload aber:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">open /var/log/caddy/kabelbruch-access.log: permission denied
</span></span></code></pre></div><p>Der Ordner gehörte dem Benutzer <code>caddy</code>, die Datei aber <code>root</code>. Der Grund ist fies: <strong><code>sudo caddy validate</code> öffnet beim Prüfen auch die Protokolldateien</strong> – und legt eine neue Datei dabei als <code>root</code> an. Der Caddy-Dienst läuft als <code>caddy</code> und darf sie danach nicht öffnen. Die Lösung:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo chown caddy:caddy /var/log/caddy/kabelbruch-access.log
</span></span><span class="line"><span class="cl">sudo systemctl reload caddy
</span></span></code></pre></div><p>Die gute Nachricht: Ein gescheiterter Reload lässt die <strong>alte</strong> Konfiguration weiterlaufen. Die Seite war nie weg.</p>
<p>Der Test danach zeigte genau das Gewünschte – meine eigene Adresse, sauber gekürzt:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;remote_ip&#34;</span><span class="err">:</span> <span class="s2">&#34;203.0.113.0&#34;</span><span class="err">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;client_ip&#34;</span><span class="err">:</span> <span class="s2">&#34;203.0.113.0&#34;</span><span class="err">,</span>
</span></span></code></pre></div><h2 id="schritt-4-jede-nacht-ein-abgeschlossener-tag">Schritt 4: Jede Nacht ein abgeschlossener Tag</h2>
<p>Hier steckt die wichtigste Designentscheidung. Das Import-Skript von Matomo merkt sich <strong>nicht</strong>, bis wohin es eine Datei schon gelesen hat. Würde ich stündlich dieselbe wachsende Datei importieren, wären alle Besuche mehrfach gezählt. Also schließt <code>logrotate</code> jede Nacht einen Tag ab, und importiert wird nur, was abgeschlossen ist – jede Datei genau einmal. Die Statistik ist dadurch immer auf dem Stand von gestern. Damit kann ich leben.</p>
<p><code>/etc/logrotate.d/caddy-kabelbruch</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">/var/log/caddy/kabelbruch-access.log {
</span></span><span class="line"><span class="cl">    daily
</span></span><span class="line"><span class="cl">    rotate 14
</span></span><span class="line"><span class="cl">    maxage 14
</span></span><span class="line"><span class="cl">    dateext
</span></span><span class="line"><span class="cl">    dateyesterday
</span></span><span class="line"><span class="cl">    copytruncate
</span></span><span class="line"><span class="cl">    missingok
</span></span><span class="line"><span class="cl">    notifempty
</span></span><span class="line"><span class="cl">    nocompress
</span></span><span class="line"><span class="cl">    olddir /var/log/caddy-export
</span></span><span class="line"><span class="cl">    postrotate
</span></span><span class="line"><span class="cl">        chgrp logpull /var/log/caddy-export/kabelbruch-access.log-* 2&gt;/dev/null || true
</span></span><span class="line"><span class="cl">        chmod 0640 /var/log/caddy-export/kabelbruch-access.log-* 2&gt;/dev/null || true
</span></span><span class="line"><span class="cl">    endscript
</span></span><span class="line"><span class="cl">}
</span></span></code></pre></div><ul>
<li><strong>Nach 14 Tagen</strong> werden die Tagesdateien gelöscht. Weil jede Nacht rotiert wird, ist garantiert nichts älter – bei einer Rotation nur nach Dateigröße bliebe bei wenig Besuch womöglich eine Datei monatelang offen.</li>
<li><strong><code>dateyesterday</code></strong> benennt die Datei nach dem Tag, dessen Zugriffe sie enthält.</li>
<li><strong><code>copytruncate</code></strong> kopiert und leert die laufende Datei, Caddy schreibt einfach weiter.</li>
<li>Die fertigen Tagesdateien landen in einem eigenen Export-Ordner und bekommen die Gruppe <code>logpull</code> – dazu gleich mehr. Die laufende Datei bleibt für diesen Benutzer unsichtbar.</li>
</ul>
<p>Ein Detail, das mich einen Tag gekostet hat: Beim <strong>allerersten</strong> Lauf legt <code>logrotate</code> nur seinen Status an und rotiert noch nichts. Die erste Tagesdatei gab es also erst eine Nacht später.</p>
<h2 id="schritt-5-abholen--nur-lesen-nur-ausgehend">Schritt 5: Abholen – nur lesen, nur ausgehend</h2>
<p>Das Homelab holt die Tagesdateien per <code>rsync</code> über SSH ab. Auf dem VPS gibt es dafür einen eigenen Benutzer <code>logpull</code>, und dessen Schlüssel ist doppelt eingeschränkt:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">restrict,command=&#34;/usr/bin/rrsync -ro /var/log/caddy-export&#34; ssh-ed25519 AAAA… homelab-logpull
</span></span></code></pre></div><p>Das ist dasselbe Muster wie beim Deploy-Schlüssel aus <a href="/posts/kabelbruch-teil-3-pipeline/">Teil 3 der Serie</a>, nur umgekehrt: <strong><code>-ro</code></strong> macht <code>rrsync</code> zum reinen Lesezugang. Beide Gegenproben gehören dazu:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ ssh -i id_logpull logpull@203.0.113.10
</span></span><span class="line"><span class="cl">PTY allocation request failed on channel 0
</span></span><span class="line"><span class="cl">/usr/bin/rrsync error: Not invoked via sshd
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ rsync schreibtest.txt logpull@203.0.113.10:
</span></span><span class="line"><span class="cl">/usr/bin/rrsync error: sending to read-only server is not allowed
</span></span></code></pre></div><p>Keine Shell, kein Schreiben. Selbst wenn jemand diesen Schlüssel stiehlt, kann er damit nur gekürzte Protokolle der letzten 14 Tage lesen.</p>
<p>Den Host-Schlüssel des VPS habe ich wie gewohnt per <code>ssh-keyscan</code> geholt und den Fingerprint gegen den Server abgeglichen, statt <code>StrictHostKeyChecking=no</code> zu setzen. Und weil der Docker-Host bisher kein <code>rsync</code> brauchte, gab es zwischendurch noch ein schlichtes <code>sudo: rsync: command not found</code> – <code>apt install rsync</code>, weiter.</p>
<h2 id="schritt-6-der-import">Schritt 6: Der Import</h2>
<p>Das Import-Skript <code>import_logs.py</code> liegt im Matomo-Image unter <code>misc/log-analytics/</code> – also immer passend zur installierten Version. Es versteht Caddys JSON-Protokolle direkt (Format <code>caddy_json</code>) und liest daraus die gekürzte IP, die aufgerufene Seite, den Referrer und die Browser-Kennung. Bots, statische Dateien (CSS, Bilder, Favicons), Weiterleitungen und Fehlerseiten sortiert es standardmäßig aus.</p>
<p>Den Zugangsschlüssel (Token) für Matomo bekommt das Skript <strong>aus einer Datei</strong> statt über die Kommandozeile, wo andere Prozesse ihn sehen könnten:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[auth]</span>
</span></span><span class="line"><span class="cl"><span class="na">token_auth</span> <span class="o">=</span> <span class="s">&lt;TOKEN&gt;</span>
</span></span></code></pre></div><p>Datei mit Rechten <code>600</code>, sonst warnt das Skript selbst.</p>
<p>Damit auf dem Docker-Host kein Python installiert werden muss, läuft der Import in einem Wegwerf-Container. Ein Probelauf mit <code>--dry-run</code> speichert nichts:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker run --rm <span class="se">\
</span></span></span><span class="line"><span class="cl">  -v /opt/stacks/matomo/matomo/misc/log-analytics:/la:ro <span class="se">\
</span></span></span><span class="line"><span class="cl">  -v /opt/matomo-logimport/incoming:/logs:ro <span class="se">\
</span></span></span><span class="line"><span class="cl">  -v /opt/matomo-logimport/auth.ini:/auth.ini:ro <span class="se">\
</span></span></span><span class="line"><span class="cl">  python:3.13-slim python3 /la/import_logs.py <span class="se">\
</span></span></span><span class="line"><span class="cl">    --url<span class="o">=</span>https://matomo.home.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">    --auth-config<span class="o">=</span>/auth.ini <span class="se">\
</span></span></span><span class="line"><span class="cl">    --idsite<span class="o">=</span><span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --log-format-name<span class="o">=</span>caddy_json <span class="se">\
</span></span></span><span class="line"><span class="cl">    --dry-run <span class="se">\
</span></span></span><span class="line"><span class="cl">    /logs/kabelbruch-access.log-test
</span></span></code></pre></div><p>Mit den ersten elf Zeilen sah das so aus:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">    6 requests imported successfully
</span></span><span class="line"><span class="cl">    5 requests ignored:
</span></span><span class="line"><span class="cl">        2 requests done by bots, search engines...
</span></span><span class="line"><span class="cl">        3 requests to static resources (css, js, images, ico, ttf...)
</span></span></code></pre></div><p>Sechs echte Seitenaufrufe, zwei Bots (einer davon war mein eigener <code>curl</code>-Test, der sich ehrlich als Werkzeug ausgibt) und drei statische Dateien. Genau so sollte es sein.</p>
<p>Der echte Ablauf steckt in einem kleinen Skript, das ein systemd-Timer jede Nacht um 03:15 Uhr startet:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">D</span><span class="o">=</span>/opt/matomo-logimport
</span></span><span class="line"><span class="cl"><span class="nv">SSH</span><span class="o">=</span><span class="s2">&#34;ssh -i </span><span class="nv">$D</span><span class="s2">/id_logpull -o UserKnownHostsFile=</span><span class="nv">$D</span><span class="s2">/known_hosts -o StrictHostKeyChecking=yes -o BatchMode=yes&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 1) Neue Tagesdateien abholen</span>
</span></span><span class="line"><span class="cl">rsync -a --ignore-existing -e <span class="s2">&#34;</span><span class="nv">$SSH</span><span class="s2">&#34;</span> logpull@203.0.113.10: <span class="s2">&#34;</span><span class="nv">$D</span><span class="s2">/incoming/&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2) Jede Datei genau einmal importieren</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> f in <span class="s2">&#34;</span><span class="nv">$D</span><span class="s2">&#34;</span>/incoming/kabelbruch-access.log-*<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> -e <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">||</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nv">name</span><span class="o">=</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> -e <span class="s2">&#34;</span><span class="nv">$D</span><span class="s2">/imported/</span><span class="nv">$name</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  docker run --rm <span class="se">\
</span></span></span><span class="line"><span class="cl">    -v /opt/stacks/matomo/matomo/misc/log-analytics:/la:ro <span class="se">\
</span></span></span><span class="line"><span class="cl">    -v <span class="s2">&#34;</span><span class="nv">$D</span><span class="s2">/incoming&#34;</span>:/logs:ro <span class="se">\
</span></span></span><span class="line"><span class="cl">    -v <span class="s2">&#34;</span><span class="nv">$D</span><span class="s2">/auth.ini&#34;</span>:/auth.ini:ro <span class="se">\
</span></span></span><span class="line"><span class="cl">    python:3.13-slim python3 /la/import_logs.py <span class="se">\
</span></span></span><span class="line"><span class="cl">      --url<span class="o">=</span>https://matomo.home.example.com --auth-config<span class="o">=</span>/auth.ini <span class="se">\
</span></span></span><span class="line"><span class="cl">      --idsite<span class="o">=</span><span class="m">1</span> --log-format-name<span class="o">=</span>caddy_json <span class="s2">&#34;/logs/</span><span class="nv">$name</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  touch <span class="s2">&#34;</span><span class="nv">$D</span><span class="s2">/imported/</span><span class="nv">$name</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3) Berichte berechnen (inkl. Löschen alter Rohdaten)</span>
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u www-data matomo php /var/www/html/console core:archive <span class="se">\
</span></span></span><span class="line"><span class="cl">  --force-all-websites --url<span class="o">=</span>https://matomo.home.example.com
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 4) Aufräumen</span>
</span></span><span class="line"><span class="cl">find <span class="s2">&#34;</span><span class="nv">$D</span><span class="s2">/incoming&#34;</span> -name <span class="s1">&#39;kabelbruch-access.log-*&#39;</span> -mtime +14 -delete
</span></span><span class="line"><span class="cl">find <span class="s2">&#34;</span><span class="nv">$D</span><span class="s2">/imported&#34;</span> -type f -mtime +30 -delete
</span></span></code></pre></div><ul>
<li>Nach jedem erfolgreichen Import entsteht eine <strong>Marker-Datei</strong>. Schlägt ein Import fehl, bricht das Skript ab, der Marker fehlt – und am nächsten Tag wird es erneut versucht.</li>
<li>Die Marker bleiben 30 Tage, also länger, als die Dateien auf dem VPS existieren. So wird nichts doppelt geholt und doppelt gezählt.</li>
<li><strong><code>core:archive</code></strong> rechnet die Berichte und erledigt Matomos geplante Aufgaben – darunter das Löschen der Rohdaten nach 30 Tagen. Im Browser lasse ich Matomo deshalb gar nicht mehr rechnen (<em>System → Allgemeine Einstellungen → Berichte archivieren, wenn sie im Browser angezeigt werden: Nein</em>).</li>
</ul>
<p>Der Timer mit <code>Persistent=true</code> holt einen verpassten Lauf nach, falls der Docker-Host um 03:15 Uhr gerade neu gestartet wurde.</p>
<h2 id="schritt-7-die-datenschutzerklärung--im-selben-moment">Schritt 7: Die Datenschutzerklärung – im selben Moment</h2>
<p>In meiner Datenschutzerklärung stand bis dahin ausdrücklich: „Es werden keine Zugriffsprotokolle gespeichert.“ Das wäre ab dem Einschalten des Protokolls schlicht falsch gewesen. Deshalb habe ich den Text vorbereitet und <strong>im selben Moment</strong> veröffentlicht, in dem das Protokoll eingeschaltet wurde.</p>
<p>Der Abschnitt beschreibt jetzt genau die Technik von oben:</p>
<ul>
<li>welche Daten der Browser überträgt,</li>
<li>dass die IP-Adresse schon beim Schreiben gekürzt wird,</li>
<li>dass das Zugriffsprotokoll nach 14 Tagen gelöscht wird,</li>
<li>dass die Statistik mit selbst betriebenem Matomo entsteht, ohne Skript, ohne Cookies und ohne Weitergabe,</li>
<li>dass Einzelbesuche nach 30 Tagen gelöscht werden und nur zusammengefasste Zahlen bleiben,</li>
<li>Rechtsgrundlage und Widerspruchsrecht.</li>
</ul>
<p>Auch der Satz ganz oben änderte sich von „keine Analyse- oder Tracking-Werkzeuge“ zu „keine Tracking-Skripte“ mit Verweis auf die serverseitige Statistik. Mein Grundsatz aus <a href="/posts/kabelbruch-teil-4-mail-impressum-favicon/">Teil 4</a> gilt weiter: Die Datenschutzerklärung beschreibt die Technik – nicht umgekehrt.</p>
<h2 id="schritt-8-ein-dashboard-das-zu-log-daten-passt">Schritt 8: Ein Dashboard, das zu Log-Daten passt</h2>
<p>Viele Standard-Widgets von Matomo setzen den JavaScript-Tracker voraus und bleiben beim Log-Import leer. Ein eigenes, schlankes Dashboard ist deshalb deutlich übersichtlicher:</p>
<table>
	<thead>
			<tr>
					<th>Funktioniert gut</th>
					<th>Eingeschränkt</th>
					<th>Bleibt leer</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Besuche, Seitenansichten, Verlauf</td>
					<td>Seitentitel (nur URLs im Log)</td>
					<td>Bildschirmauflösung</td>
			</tr>
			<tr>
					<td>Seiten und Einstiegsseiten</td>
					<td>Länder (wegen der gekürzten IPs nur grob)</td>
					<td>Ladezeiten</td>
			</tr>
			<tr>
					<td>Herkunft: Kanaltypen, Websites, soziale Netzwerke</td>
					<td>Suchbegriffe (fast immer „nicht definiert“)</td>
					<td>Klicks auf externe Links, Downloads</td>
			</tr>
			<tr>
					<td>Gerätetyp, Browser, Betriebssystem</td>
					<td>Verweildauer (nur geschätzt)</td>
					<td>Ereignisse, alles mit „Echtzeit“</td>
			</tr>
			<tr>
					<td>Besuche pro Stunde (Zeitzone der Website), Wochentag</td>
					<td></td>
					<td>„Besuche nach lokaler Zeit“</td>
			</tr>
	</tbody>
</table>
<p>Mein Aufbau in drei Spalten:</p>
<ul>
<li><strong>Wie läuft es?</strong> Besucherübersicht, Graph der letzten Besuche, Seiten</li>
<li><strong>Woher kommen die Leser?</strong> Kanaltypen, Websites, soziale Netzwerke, Einstiegsseiten</li>
<li><strong>Wer liest – und wann?</strong> Gerätetyp, Browser, Betriebssystem-Familien, Besuche pro Stunde</li>
</ul>
<p>Zwei Einstellungen unter <em>Persönlich → Einstellungen</em> machen viel aus:</p>
<ul>
<li><strong>Standardberichtsdatum: „Vergangene 7 Tage (exkl. heute)“.</strong> Der heutige Tag ist bei Log-Import immer leer. Und sieben Tage zeigen bei einem jungen Blog mehr als ein einzelner Tag mit zwei Besuchern.</li>
<li><strong>Eigene Besuche nicht per IP ausschließen.</strong> Durch das Kürzen steht nur noch der Adressbereich meines Providers in Matomo – damit würde ich auch die Nachbarn bei meinem Provider ausblenden.</li>
</ul>
<p>Kleiner Bedienhinweis: Widgets lassen sich nur an der <strong>Titelleiste</strong> verschieben, und nur, wenn das Browserfenster breit genug für mehrere Spalten ist.</p>
<h2 id="der-erste-echte-import">Der erste echte Import</h2>
<p>Der erste nächtliche Import läuft in der Nacht, nachdem dieser Artikel online gegangen ist. Ob alles geklappt hat, welche Artikel gelesen werden und woher die Leser kommen, ergänze ich hier als Update – inklusive Screenshot vom Dashboard. Und falls dabei etwas schiefgeht, steht es natürlich auch hier.</p>
<h2 id="stolperfallen--learnings">Stolperfallen &amp; Learnings</h2>
<ul>
<li><strong>JavaScript-Tracking braucht in Deutschland in aller Regel eine Einwilligung</strong> – auch ohne Cookies. Logdateien auszuwerten nicht.</li>
<li><strong>Der Import zählt doppelt</strong>, wenn man dieselbe Datei mehrfach importiert. Deshalb: abgeschlossene Tagesdateien, Marker pro Datei.</li>
<li><strong><code>sudo caddy validate</code> legt neue Logdateien als <code>root</code> an.</strong> Danach scheitert der Reload mit <code>permission denied</code> – Besitzer korrigieren oder die Datei vorher als <code>caddy</code> anlegen.</li>
<li><strong><code>logrotate</code> rotiert beim ersten Lauf noch nichts.</strong></li>
<li><strong>Vor Änderungen an <code>config.ini.php</code> erst <code>grep</code>.</strong> Der Installer setzt manche Proxy-Einstellungen selbst.</li>
<li><strong>Sicherungskopien nicht ins Web-Verzeichnis legen.</strong> Matomo merkt es, ein Angreifer vielleicht auch.</li>
<li><strong>Token per Datei übergeben</strong>, nicht per Kommandozeile – und an das Ablaufdatum denken.</li>
<li><strong>Datenschutzerklärung und Technik gleichzeitig ändern.</strong></li>
</ul>
<h2 id="fazit--ausblick">Fazit &amp; Ausblick</h2>
<p>Ich weiß jetzt, ob und was gelesen wird – ohne dass im Browser meiner Leser irgendetwas passiert, ohne Banner und ohne dass die Daten mein Homelab verlassen. Der Preis dafür ist überschaubar: Die Zahlen sind einen Tag alt, und ein paar Kennzahlen wie Bildschirmgröße oder genaue Verweildauer gibt es nicht. Für einen Blog brauche ich die auch nicht.</p>
<p><strong>Was als Nächstes kommt:</strong></p>
<ul>
<li>Der Matomo-Token hat ein Ablaufdatum. Weil mein Matomo noch keine Mails verschicken kann, käme die Erinnerung nicht an. Also: SMTP über meinen eigenen Mailserver einrichten – dann gehen auch Wochenberichte per Mail.</li>
<li>Eine Benachrichtigung, falls ein Import einmal fehlschlägt.</li>
<li>Und vielleicht doch noch Grafana: nicht für Besucherzahlen, sondern für den <strong>Betrieb</strong> der Seite – Fehlerseiten, Antwortzeiten, auffällige Zugriffe. Die Logs liegen ja jetzt ohnehin im Homelab.</li>
</ul>
]]></content:encoded></item></channel></rss>