Kaum war die Serie über den Aufbau dieser Seite online, kam die naheliegende Frage: Liest das eigentlich jemand? Und wenn ja – was, und woher kommen die Leute?

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.

Die Ausgangslage – und eine kurze Rechtsfrage

Ich bin kein Anwalt, aber eine Regel ist für Besucherstatistiken in Deutschland zentral: § 25 TDDDG (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 Einwilligung. Daher kommen die Banner.

Eine Auswertung, die nur auf dem Server passiert und ausschließlich nutzt, was der Browser ohnehin mitschickt, greift dagegen gar nicht auf das Gerät zu. Matomo schreibt selbst, dass seine Log-Analytics-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.

Damit war die Richtung klar: Statistik aus Server-Logs.

Welches Werkzeug?

Matomo (Log-Import)Grafana + LokiGoAccessJavaScript-Tools
Besuche erkennen, Bots filtern✅ eingebaut❌ selbst bauen✅✅
Herkunft (Suchmaschine, Social Media)✅ automatisch⚠️ nur rohe Referrer✅✅
Langzeit-Berichte✅⚠️ je nach Aufbewahrung⚠️✅
Kein Banner, keine CSP-Änderung✅✅✅❌
Aufwandmittelmittelgeringgering bis mittel

Matomo 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.

Der Aufbau

Der Clou ist, wo 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:

VPS: Caddy schreibt Zugriffsprotokoll (IP gekürzt)
       │ logrotate, jede Nacht
       ▼
     /var/log/caddy-export/kabelbruch-access.log-JJJJMMTT
       ▲
       │ rsync über SSH – nur lesen, nur ausgehend vom Homelab
       │
Homelab (Docker-Host): Import-Skript ──▶ Matomo (nur intern erreichbar)

Beispielwerte in diesem Artikel: example.com ist die Blog-Domain, 203.0.113.10 der VPS, matomo.home.example.com die interne Adresse von Matomo.

Voraussetzungen:

Version / Stand
VPSDebian 13, Caddy 2 (aus Teil 2 der Serie)
HomelabDocker-Host mit Docker Compose und einem Reverse Proxy (bei mir Nginx Proxy Manager)
Matomo5.14.2 (offizielles Docker-Image matomo:5.14.2-apache)
DatenbankMariaDB 11.4 (LTS)
Importimport_logs.py aus dem Matomo-Image, ausgeführt in python:3.13-slim

Schritt 1: Matomo im Homelab

Matomo bekommt einen eigenen Compose-Stack. Die Passwörter liegen in einer .env-Datei – und ich erzeuge sie bewusst nur aus Buchstaben und Ziffern, weil Docker Compose ein $ im Passwort sonst als Variable interpretiert (darüber bin ich bei einem anderen Dienst schon einmal gestolpert):

DBPW=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
ROOTPW=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
printf "MATOMO_DB_PASSWORD='%s'\nMARIADB_ROOT_PASSWORD='%s'\n" "$DBPW" "$ROOTPW" | sudo tee .env > /dev/null
sudo chmod 600 .env

Die compose.yaml:

services:
  matomo-db:
    image: mariadb:11.4
    container_name: matomo-db
    restart: unless-stopped
    command: --max-allowed-packet=64MB
    environment:
      - MARIADB_DATABASE=matomo
      - MARIADB_USER=matomo
      - MARIADB_PASSWORD=${MATOMO_DB_PASSWORD}
      - MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD}
      - TZ=Europe/Berlin
    volumes:
      - ./db:/var/lib/mysql

  matomo:
    image: matomo:5.14.2-apache
    container_name: matomo
    restart: unless-stopped
    depends_on:
      - matomo-db
    environment:
      - MATOMO_DATABASE_HOST=matomo-db
      - MATOMO_DATABASE_ADAPTER=mysql
      - MATOMO_DATABASE_TABLES_PREFIX=matomo_
      - MATOMO_DATABASE_USERNAME=matomo
      - MATOMO_DATABASE_PASSWORD=${MATOMO_DB_PASSWORD}
      - MATOMO_DATABASE_DBNAME=matomo
      - TZ=Europe/Berlin
    volumes:
      - ./matomo:/var/www/html
    networks:
      - default
      - reverse-proxy

networks:
  reverse-proxy:
    external: true

Wichtig daran: Kein Port wird veröffentlicht. Matomo hängt nur im Netz des Reverse Proxys, die Datenbank nicht einmal dort. Erreichbar ist Matomo ausschließlich intern über https://matomo.home.example.com.

Der Installationsassistent ist schnell durchgeklickt. Zwei Hinweise dazu:

  • Bei der Datenbank fragt Matomo inzwischen nach MySQL oder MariaDB – hier MariaDB wählen.
  • Den angezeigten Tracking-Code einfach überspringen. Den brauchen wir nicht.

Matomo hinter dem Reverse Proxy

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 [General] der config/config.ini.php:

[General]
assume_secure_protocol = 1
force_ssl = 1
proxy_host_headers[] = HTTP_X_FORWARDED_HOST
enable_auto_update = 0

Vorher lohnt ein grep: Der Installer hatte proxy_client_headers[] und proxy_scheme_headers[] bereits selbst eingetragen, als er den Proxy erkannt hat. Doppelte Einträge braucht niemand. enable_auto_update = 0 schaltet das Update aus der Weboberfläche ab – bei einer Docker-Installation kommt das Update über einen neuen Image-Tag.

Kleine Falle am Rande: Meine Sicherungskopie config.ini.php.bak lag zunächst im selben Ordner. Prompt meldete die Systemprüfung „File integrity: Files were found … we didn’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.

Schritt 2: Datenschutz-Einstellungen in Matomo

Unter Datenschutz → Daten anonymisieren:

  • IP-Adressen anonymisieren: 2 Bytes. 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.
  • Alte Rohdaten regelmäßig löschen: nach 30 Tagen. Einzelne Besuche verschwinden, die zusammengefassten Berichte (Aufrufe pro Tag, Monat, Jahr) bleiben.
  • Alte Berichte löschen: aus. Die enthalten nichts Personenbeziehbares mehr.

Dazu unter Opt-Out für Benutzer „Do Not Track“ unterstützen. Beim Log-Import hat das kaum Wirkung, ist aber ein gutes Signal.

Schritt 3: Das Zugriffsprotokoll auf dem VPS

Bisher schrieb Caddy gar kein Zugriffsprotokoll. Jetzt bekommt der Seitenblock im Caddyfile einen log-Abschnitt:

example.com {
    root * /var/www/kabelbruch
    log {
        output file /var/log/caddy/kabelbruch-access.log {
            roll_disabled
            mode 0640
        }
        format filter {
            request>remote_ip ip_mask 24 48
            request>client_ip ip_mask 24 48
            request>headers>Cookie delete
            request>headers>Authorization delete
        }
    }
    # … Rest wie gehabt
}
  • ip_mask 24 48 kürzt IPv4-Adressen auf die ersten drei Blöcke (203.0.113.47 wird zu 203.0.113.0) und IPv6 auf die ersten 48 Bit – bevor irgendetwas auf die Platte geschrieben wird.
  • Cookie und Authorization fliegen raus. Die Seite setzt keine Cookies, aber falls ein Browser doch welche mitschickt, landen sie nicht im Protokoll.
  • roll_disabled: Caddy rotiert nicht selbst – das übernimmt gleich logrotate.

Gescheitert: permission denied

caddy validate meldete „Valid configuration“, der anschließende Reload aber:

open /var/log/caddy/kabelbruch-access.log: permission denied

Der Ordner gehörte dem Benutzer caddy, die Datei aber root. Der Grund ist fies: sudo caddy validate öffnet beim Prüfen auch die Protokolldateien – und legt eine neue Datei dabei als root an. Der Caddy-Dienst läuft als caddy und darf sie danach nicht öffnen. Die Lösung:

sudo chown caddy:caddy /var/log/caddy/kabelbruch-access.log
sudo systemctl reload caddy

Die gute Nachricht: Ein gescheiterter Reload lässt die alte Konfiguration weiterlaufen. Die Seite war nie weg.

Der Test danach zeigte genau das Gewünschte – meine eigene Adresse, sauber gekürzt:

"remote_ip": "203.0.113.0",
"client_ip": "203.0.113.0",

Schritt 4: Jede Nacht ein abgeschlossener Tag

Hier steckt die wichtigste Designentscheidung. Das Import-Skript von Matomo merkt sich nicht, 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 logrotate 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.

/etc/logrotate.d/caddy-kabelbruch:

/var/log/caddy/kabelbruch-access.log {
    daily
    rotate 14
    maxage 14
    dateext
    dateyesterday
    copytruncate
    missingok
    notifempty
    nocompress
    olddir /var/log/caddy-export
    postrotate
        chgrp logpull /var/log/caddy-export/kabelbruch-access.log-* 2>/dev/null || true
        chmod 0640 /var/log/caddy-export/kabelbruch-access.log-* 2>/dev/null || true
    endscript
}
  • Nach 14 Tagen 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.
  • dateyesterday benennt die Datei nach dem Tag, dessen Zugriffe sie enthält.
  • copytruncate kopiert und leert die laufende Datei, Caddy schreibt einfach weiter.
  • Die fertigen Tagesdateien landen in einem eigenen Export-Ordner und bekommen die Gruppe logpull – dazu gleich mehr. Die laufende Datei bleibt für diesen Benutzer unsichtbar.

Ein Detail, das mich einen Tag gekostet hat: Beim allerersten Lauf legt logrotate nur seinen Status an und rotiert noch nichts. Die erste Tagesdatei gab es also erst eine Nacht später.

Schritt 5: Abholen – nur lesen, nur ausgehend

Das Homelab holt die Tagesdateien per rsync über SSH ab. Auf dem VPS gibt es dafür einen eigenen Benutzer logpull, und dessen Schlüssel ist doppelt eingeschränkt:

restrict,command="/usr/bin/rrsync -ro /var/log/caddy-export" ssh-ed25519 AAAA… homelab-logpull

Das ist dasselbe Muster wie beim Deploy-Schlüssel aus Teil 3 der Serie, nur umgekehrt: -ro macht rrsync zum reinen Lesezugang. Beide Gegenproben gehören dazu:

$ ssh -i id_logpull logpull@203.0.113.10
PTY allocation request failed on channel 0
/usr/bin/rrsync error: Not invoked via sshd

$ rsync schreibtest.txt logpull@203.0.113.10:
/usr/bin/rrsync error: sending to read-only server is not allowed

Keine Shell, kein Schreiben. Selbst wenn jemand diesen Schlüssel stiehlt, kann er damit nur gekürzte Protokolle der letzten 14 Tage lesen.

Den Host-Schlüssel des VPS habe ich wie gewohnt per ssh-keyscan geholt und den Fingerprint gegen den Server abgeglichen, statt StrictHostKeyChecking=no zu setzen. Und weil der Docker-Host bisher kein rsync brauchte, gab es zwischendurch noch ein schlichtes sudo: rsync: command not found – apt install rsync, weiter.

Schritt 6: Der Import

Das Import-Skript import_logs.py liegt im Matomo-Image unter misc/log-analytics/ – also immer passend zur installierten Version. Es versteht Caddys JSON-Protokolle direkt (Format caddy_json) 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.

Den Zugangsschlüssel (Token) für Matomo bekommt das Skript aus einer Datei statt über die Kommandozeile, wo andere Prozesse ihn sehen könnten:

[auth]
token_auth = <TOKEN>

Datei mit Rechten 600, sonst warnt das Skript selbst.

Damit auf dem Docker-Host kein Python installiert werden muss, läuft der Import in einem Wegwerf-Container. Ein Probelauf mit --dry-run speichert nichts:

docker run --rm \
  -v /opt/stacks/matomo/matomo/misc/log-analytics:/la:ro \
  -v /opt/matomo-logimport/incoming:/logs:ro \
  -v /opt/matomo-logimport/auth.ini:/auth.ini:ro \
  python:3.13-slim python3 /la/import_logs.py \
    --url=https://matomo.home.example.com \
    --auth-config=/auth.ini \
    --idsite=1 \
    --log-format-name=caddy_json \
    --dry-run \
    /logs/kabelbruch-access.log-test

Mit den ersten elf Zeilen sah das so aus:

    6 requests imported successfully
    5 requests ignored:
        2 requests done by bots, search engines...
        3 requests to static resources (css, js, images, ico, ttf...)

Sechs echte Seitenaufrufe, zwei Bots (einer davon war mein eigener curl-Test, der sich ehrlich als Werkzeug ausgibt) und drei statische Dateien. Genau so sollte es sein.

Der echte Ablauf steckt in einem kleinen Skript, das ein systemd-Timer jede Nacht um 03:15 Uhr startet:

#!/bin/bash
set -euo pipefail
D=/opt/matomo-logimport
SSH="ssh -i $D/id_logpull -o UserKnownHostsFile=$D/known_hosts -o StrictHostKeyChecking=yes -o BatchMode=yes"

# 1) Neue Tagesdateien abholen
rsync -a --ignore-existing -e "$SSH" logpull@203.0.113.10: "$D/incoming/"

# 2) Jede Datei genau einmal importieren
for f in "$D"/incoming/kabelbruch-access.log-*; do
  [ -e "$f" ] || continue
  name=$(basename "$f")
  [ -e "$D/imported/$name" ] && continue
  docker run --rm \
    -v /opt/stacks/matomo/matomo/misc/log-analytics:/la:ro \
    -v "$D/incoming":/logs:ro \
    -v "$D/auth.ini":/auth.ini:ro \
    python:3.13-slim python3 /la/import_logs.py \
      --url=https://matomo.home.example.com --auth-config=/auth.ini \
      --idsite=1 --log-format-name=caddy_json "/logs/$name"
  touch "$D/imported/$name"
done

# 3) Berichte berechnen (inkl. Löschen alter Rohdaten)
docker exec -u www-data matomo php /var/www/html/console core:archive \
  --force-all-websites --url=https://matomo.home.example.com

# 4) Aufräumen
find "$D/incoming" -name 'kabelbruch-access.log-*' -mtime +14 -delete
find "$D/imported" -type f -mtime +30 -delete
  • Nach jedem erfolgreichen Import entsteht eine Marker-Datei. Schlägt ein Import fehl, bricht das Skript ab, der Marker fehlt – und am nächsten Tag wird es erneut versucht.
  • Die Marker bleiben 30 Tage, also länger, als die Dateien auf dem VPS existieren. So wird nichts doppelt geholt und doppelt gezählt.
  • core:archive 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 (System → Allgemeine Einstellungen → Berichte archivieren, wenn sie im Browser angezeigt werden: Nein).

Der Timer mit Persistent=true holt einen verpassten Lauf nach, falls der Docker-Host um 03:15 Uhr gerade neu gestartet wurde.

Schritt 7: Die Datenschutzerklärung – im selben Moment

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 im selben Moment veröffentlicht, in dem das Protokoll eingeschaltet wurde.

Der Abschnitt beschreibt jetzt genau die Technik von oben:

  • welche Daten der Browser überträgt,
  • dass die IP-Adresse schon beim Schreiben gekürzt wird,
  • dass das Zugriffsprotokoll nach 14 Tagen gelöscht wird,
  • dass die Statistik mit selbst betriebenem Matomo entsteht, ohne Skript, ohne Cookies und ohne Weitergabe,
  • dass Einzelbesuche nach 30 Tagen gelöscht werden und nur zusammengefasste Zahlen bleiben,
  • Rechtsgrundlage und Widerspruchsrecht.

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 Teil 4 gilt weiter: Die Datenschutzerklärung beschreibt die Technik – nicht umgekehrt.

Schritt 8: Ein Dashboard, das zu Log-Daten passt

Viele Standard-Widgets von Matomo setzen den JavaScript-Tracker voraus und bleiben beim Log-Import leer. Ein eigenes, schlankes Dashboard ist deshalb deutlich übersichtlicher:

Funktioniert gutEingeschränktBleibt leer
Besuche, Seitenansichten, VerlaufSeitentitel (nur URLs im Log)Bildschirmauflösung
Seiten und EinstiegsseitenLänder (wegen der gekürzten IPs nur grob)Ladezeiten
Herkunft: Kanaltypen, Websites, soziale NetzwerkeSuchbegriffe (fast immer „nicht definiert“)Klicks auf externe Links, Downloads
Gerätetyp, Browser, BetriebssystemVerweildauer (nur geschätzt)Ereignisse, alles mit „Echtzeit“
Besuche pro Stunde (Zeitzone der Website), Wochentag„Besuche nach lokaler Zeit“

Mein Aufbau in drei Spalten:

  • Wie läuft es? Besucherübersicht, Graph der letzten Besuche, Seiten
  • Woher kommen die Leser? Kanaltypen, Websites, soziale Netzwerke, Einstiegsseiten
  • Wer liest – und wann? Gerätetyp, Browser, Betriebssystem-Familien, Besuche pro Stunde

Zwei Einstellungen unter Persönlich → Einstellungen machen viel aus:

  • Standardberichtsdatum: „Vergangene 7 Tage (exkl. heute)“. 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.
  • Eigene Besuche nicht per IP ausschließen. Durch das Kürzen steht nur noch der Adressbereich meines Providers in Matomo – damit würde ich auch die Nachbarn bei meinem Provider ausblenden.

Kleiner Bedienhinweis: Widgets lassen sich nur an der Titelleiste verschieben, und nur, wenn das Browserfenster breit genug für mehrere Spalten ist.

Der erste echte Import

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.

Stolperfallen & Learnings

  • JavaScript-Tracking braucht in Deutschland in aller Regel eine Einwilligung – auch ohne Cookies. Logdateien auszuwerten nicht.
  • Der Import zählt doppelt, wenn man dieselbe Datei mehrfach importiert. Deshalb: abgeschlossene Tagesdateien, Marker pro Datei.
  • sudo caddy validate legt neue Logdateien als root an. Danach scheitert der Reload mit permission denied – Besitzer korrigieren oder die Datei vorher als caddy anlegen.
  • logrotate rotiert beim ersten Lauf noch nichts.
  • Vor Änderungen an config.ini.php erst grep. Der Installer setzt manche Proxy-Einstellungen selbst.
  • Sicherungskopien nicht ins Web-Verzeichnis legen. Matomo merkt es, ein Angreifer vielleicht auch.
  • Token per Datei übergeben, nicht per Kommandozeile – und an das Ablaufdatum denken.
  • Datenschutzerklärung und Technik gleichzeitig ändern.

Fazit & Ausblick

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.

Was als Nächstes kommt:

  • 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.
  • Eine Benachrichtigung, falls ein Import einmal fehlschlägt.
  • Und vielleicht doch noch Grafana: nicht für Besucherzahlen, sondern für den Betrieb der Seite – Fehlerseiten, Antwortzeiten, auffällige Zugriffe. Die Logs liegen ja jetzt ohnehin im Homelab.