Einige Links auf dieser Seite sind Affiliate-Links: bei einer Anmeldung darüber erhält diese Seite eine Provision, für Sie ohne Mehrkosten. Preise und Empfehlungen bleiben davon unberührt.

Vor elf Tagen haben wir einen datenschutzfreundlichen Analytics-Server neben eine Seite gestellt, die bereits lief. Keine neue Maschine, kein Managed Service, kein Cookie-Banner. Es dauerte etwa zwanzig Minuten, der Server braucht jetzt 340 MB RAM, und seine Datenbank ist auf 9.5 MB gewachsen.

In diesen elf Tagen hat er 96 Pageviews erfasst, während der davorliegende Webserver 2016 erfolgreiche Seitenantworten protokolliert hat.

Beide Zahlen sind echt, beide stammen von derselben Maschine, und die Lücke dazwischen ist das Nützlichste an diesem Artikel. Fangen wir trotzdem mit der Installation an, denn die ist der leichte Teil.

Warum überhaupt selbst hosten

Drei Gründe, in der Reihenfolge, in der sie tatsächlich zählen.

Die Maschine bezahlst du sowieso. Gehostetes Analytics wird monatlich abgerechnet und skaliert mit den Pageviews, die Rechnung wächst also genau dann, wenn die Seite anfängt zu funktionieren. Ein Container neben einer bestehenden Seite kostet so viel, wie 340 MB RAM eben kosten, und das ist auf einem Server für €5.49 ein Rundungsfehler. Den Rest der Flotte haben wir in unserem Hetzner-Test und im VPS-Vergleich dimensioniert.

Kein Cookie-Banner. Umami setzt keine Cookies und baut keine seitenübergreifenden Profile auf. Das ist der Unterschied zwischen einer Seite, die mit Inhalt beginnt, und einer, die mit einem Einwilligungsdialog beginnt, und er ist mehr wert als jede Metrik, die der Dialog schützt.

Die Daten bleiben in deinem Postgres. Du kannst sie mit SQL abfragen, zusammen mit allem anderen sichern und löschen, wann du willst. Keine Export-Quote, keine Aufbewahrungsstufe.

Das ehrliche Gegenargument: Du betreibst jetzt eine Datenbank und eine App, die niemand sonst für dich patcht. Wenn du nicht einmal im Monat docker compose pull ausführst, nimm die gehostete Variante und lies nicht weiter.

Die Compose-Datei für eine Maschine, auf der schon etwas läuft

Hier machen die meisten Anleitungen den Fehler, eine leere Maschine vorauszusetzen. Der realistische Fall ist ein Server, auf dem bereits ein Reverse Proxy und eine Seite laufen, und das Ziel ist, zwei Container hinzuzufügen, ohne Funktionierendes anzufassen.

services:
  umami-db:
    image: postgres:17-alpine
    container_name: umami-db
    restart: unless-stopped
    env_file: [.env.umami]
    volumes:
      - umami_db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U umami"]
      interval: 10s
      timeout: 5s
      retries: 10

  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: umami
    restart: unless-stopped
    env_file: [.env.umami]
    depends_on:
      umami-db:
        condition: service_healthy

volumes:
  umami_db:

Zwei Details leisten hier echte Arbeit.

Keiner der Container veröffentlicht einen Port. Sie sprechen über das Compose-Netzwerk mit dem Reverse Proxy und sind aus dem Internet nur über ihn erreichbar. Wenn du aus Gewohnheit ports: ["3000:3000"] hinzufügst, hast du gerade einen Admin-Login mit Standardpasswort auf einen öffentlichen Port gelegt, und die Firewall ist das Einzige zwischen dir und einem schlechten Abend.

Der Healthcheck ist keine Dekoration. Umami führt seine Migrationen beim ersten Start aus. Startet es gegen ein Postgres, das noch initialisiert, schlägt der erste Lauf mit einem Verbindungsfehler fehl, und du suchst ein Problem, das sich beim Neustart von selbst behebt. condition: service_healthy beseitigt die Race Condition.

Die dazugehörige Datei .env.umami:

POSTGRES_DB=umami
POSTGRES_USER=umami
POSTGRES_PASSWORD=<long random string>
DATABASE_URL=postgresql://umami:<same string>@umami-db:5432/umami
APP_SECRET=<another long random string>

DATABASE_URL ist die einzige Variable, die die Anwendung wirklich zwingend braucht. APP_SECRET signiert die Authentifizierungs-Tokens, und die Dokumentation sagt ausdrücklich, dass jede Installation ihr eigenes haben sollte. Erzeuge beide auf dieselbe Weise und verwende sie nie wieder:

openssl rand -hex 32

Setze für diese Datei chmod 600. Sie enthält das Datenbankpasswort an zwei Stellen.

Anbindung an Caddy

Wenn du Caddy bereits so betreibst, wie wir es in unserem Caddy-und-Docker-Setup beschrieben haben, besteht die ganze Integration aus fünf Zeilen:

stats.yourdomain.dev {
    encode zstd gzip

    header {
        X-Content-Type-Options nosniff
        Referrer-Policy no-referrer
        -Server
    }

    reverse_proxy umami:3000
}

Caddy löst umami über das Compose-Netzwerk auf und besorgt das Zertifikat bei der ersten Anfrage. Es gibt keine TLS-Konfiguration zu schreiben, und genau deshalb lohnt es sich auf einer kleinen Maschine.

Lass den DNS-Eintrag auf den Server zeigen, bevor du neu lädst, sonst scheitert die Zertifikatsanfrage und Caddy pausiert eine Weile.

Der Schritt, den du nicht aufschieben darfst

Umami legt beim ersten Start ein Standardkonto an: Benutzername admin, Passwort umami. Das ist dokumentiert, und damit kennt es auch jeder Scanner im Internet.

Melde dich an, ändere das Passwort und beweise dann, dass das alte weg ist:

curl -s -o /dev/null -w '%{http_code}\n' \
  -X POST https://stats.yourdomain.dev/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"umami"}'

401 ist die Antwort, die du sehen willst. Alles andere bedeutet, dass die Änderung nicht gegriffen hat und du ein öffentliches Dashboard mit veröffentlichtem Passwort betreibst.

Füge dann deine Seite in der Oberfläche hinzu und setze das Snippet, das du bekommst, in den Head deines HTML:

<script defer src="https://stats.yourdomain.dev/script.js"
        data-website-id="your-website-id"></script>

Die Website-ID ist kein Geheimnis. Sie legt nur fest, zu welcher Seite ein Pageview gehört, mehr nicht.

Wie elf Tage echte Zahlen aussehen

Hier sind die gesamten Betriebskosten, gemessen auf einer Live-Maschine statt geschätzt:

MetrikWert
Umami Container Speicher302 MB
Postgres Container Speicher37 MB
Datenbankgröße, 96 Pageviews9.5 MB
CPU, beide Container, Dauerbetrieb0.00%
Uptime seit Installation, Neustarts11 Tage, keine

Die 9.5 MB sind fast ausschließlich leeres Schema. Postgres wächst bei ein paar Events nicht linear, lies aus dieser Zahl also keine Kosten pro Pageview ab. Die Speicherzahl ist die, die für die Kapazitätsplanung zählt: Plane rund 350 MB ein, dann gibt es keine Überraschungen.

Jetzt der unbequeme Teil

Gleiche Maschine, gleiche elf Tage, zwei Quellen der Wahrheit, die heftig voneinander abweichen.

Das Access Log verzeichnete 2016 erfolgreiche HTML-Antworten. Umami erfasste 96 Pageviews in 26 Sessions. Nimmst du das wörtlich, hat Analytics 4.8% deines Traffics gesehen, und du solltest in Panik geraten.

Keine Panik. Zerlege stattdessen das Log.

Filtert man User Agents heraus, die bot, spider, crawl, curl, python, wget oder headless enthalten, fallen 1143 dieser 2016 Anfragen weg. Mehr als die Hälfte des Traffics einer neuen Seite ist offen als Automatisierung deklariert, und dabei sind die, die lügen, noch gar nicht mitgezählt.

Von den verbleibenden 873 sind 196 GoogleOther - ein Google-Crawler, dessen User Agent die Zeichenfolge bot überhaupt nicht enthält. Wenn deine Log-Auswertung nach “bot” greppt, zählst du still und heimlich einen Crawler als Leser. Das solltest du dir merken, wenn dir das nächste Mal ein auf Access Logs gebautes Dashboard etwas Schmeichelhaftes erzählt.

Im Rest stecken weiterhin nicht gekennzeichnete Scraper. Eine starke Häufung genau eines uralten Safari-Builds ist zum Beispiel ein Fingerabdruck, kein Publikum.

Der echte Vergleich lautet also: 96 erfasste Pageviews gegen ein paar hundert plausibel menschliche Anfragen, und die Lücke hat drei Ursachen, nach Größe absteigend:

  1. Automatisierung, die kein JavaScript ausführt. Sie taucht im Log auf und nie in Analytics. Das ist der größte Teil der Lücke, und hier hat Analytics recht.
  2. Blockierte Tracker. Manche Filterlisten blockieren nach Hostnamen-Muster, und ein Host namens stats. ist ein Muster. Self-Hosting verleiht keine Immunität, es entfernt nur die bekannte Drittanbieter-Domain. Das Projekt dokumentiert, wie du das Skript unter deiner eigenen Domain proxyst und den Collection-Endpunkt mit COLLECT_API_ENDPOINT verschiebst, falls du diese Lücke schließen willst.
  3. Prefetch und Leser ohne JS. Klein, real, nicht behebbar.

Die Schlussfolgerung ist nicht “Logs sind ehrlich und Analytics lügt”. Sie lautet, dass beide unterschiedliche Grundgesamtheiten messen: Das Log zählt Anfragen, Analytics zählt Browser, die Skripte ausführen. Das Log einer neuen Seite besteht überwiegend aus Robotern, weshalb rohe Logs eine junge Seite beliebt wirken lassen und der einfachste Weg sind, sich bei der Reichweite selbst etwas vorzumachen.

Wenn du in den ersten Monaten an einer einzigen Zahl navigieren willst, nimm die Sessions aus Analytics und behandle das Log als Debugging-Werkzeug.

Einwilligung, kurz und vorsichtig

Cookielose Analytics umgeht die Einwilligungspflicht, die am Speichern oder Auslesen von Informationen auf dem Gerät des Besuchers hängt, und genau die macht das Banner beim herkömmlichen Tracking verpflichtend. Sie macht die Verarbeitung aber nicht unsichtbar: Eine IP-Adresse erreicht deinen Server weiterhin, wie bei jeder Anfrage an jede Seite.

Praktische Folgen, keine Rechtsberatung: Schreibe in deine Datenschutzerklärung, was du erhebst, halte es aggregiert und setze IGNORE_IP für deine eigenen Adressen, damit dein Dashboard nicht überwiegend aus dir besteht. Wenn deine Situation reguliert ist, frag jemanden mit Qualifikation und nicht einen Blog.

Wartung, und die ist der ganze Deal, den du unterschrieben hast

docker compose pull umami umami-db
docker compose up -d umami umami-db

Das ist der monatliche Job. Schema-Migrationen laufen beim Container-Start automatisch, was bequem ist und zugleich genau der Grund, warum das Volume zählt: umami_db ist das Einzige, das du nicht aus dieser Datei neu erzeugen kannst. Sichere es zusammen mit deinen übrigen Daten und teste die Wiederherstellung einmal, sonst besitzt du eine ungetestete Kopie von 9.5 MB und ein falsches Sicherheitsgefühl.

Tu nach der ersten Woche noch eines: Öffne das Dashboard und vergleiche es mit dem, was du sicher weißt. Wenn du die Seite selbst zwanzigmal besucht hast und das Dashboard zwei zeigt, wird irgendwo zwischen Script-Tag und Collector etwas blockiert. Das erfährst du besser jetzt als in drei Monaten, wenn du Entscheidungen anhand dieser Zahl triffst.