Der 35-Watt-Mitbewohner (Teil 8): Umami Analytics, oder wie ein Passwort mit Sonderzeichen alles lahmlegt

Google Analytics nervt. Nicht weil es schlecht wäre, sondern weil ich für meine kleinen Projekte keinen Cookie-Banner brauche, keine DSGVO-Kopfschmerzen will und eigentlich nur wissen möchte: kommt überhaupt jemand auf meine Seite? Und wenn ja, woher?

Bisher hatte ich das über Grafana gelöst. Im Monitoring-Post hatte ich ein Nginx-Dashboard, das mir Zugriffe, Status-Codes und Herkunftsländer angezeigt hat. Funktioniert, aber es fühlt sich an wie Analytics durch ein Schlüsselloch. Ich sehe, dass jemand /lotto6aus49 aufgerufen hat, aber nicht ob der Besucher danach noch andere Seiten angeschaut hat. Keine Sessions, keine Verweildauer, keine Referrer im Kontext. Rohe Access-Logs in hübsch, mehr nicht.

Also: Umami. Open Source, privacy-first, keine Cookies, DSGVO-konform ab Werk. Klingt nach einer Sache von zehn Minuten. War es nicht.

Der Plan

Umami braucht zwei Dinge: eine PostgreSQL-Datenbank und die App selbst. Beides als Docker-Container, hinter Traefik, Secrets aus Dateien. Wie bei allen anderen Services in meinem Setup. Eigentlich Routine.

Das Ziel-Setup:

Problem 1: Der Entrypoint-Tanz

Umami’s Docker-Image basiert auf Next.js im Standalone-Modus. Der offizielle CMD ist npm run start-docker, was Prisma-Migrationen ausführt und dann den Server startet. Soweit klar.

Nur: ich wollte die Datenbank-Credentials nicht als Umgebungsvariablen in den Compose-File schreiben (die stehen dann in Portainer im Klartext), sondern über Docker Secrets injizieren. Das heißt, ich brauche einen eigenen Entrypoint, der die Secrets liest und als Umgebungsvariablen exportiert.

Erster Versuch mit Shell-Wrapper:

entrypoint:
  - /bin/sh
  - -ec
  - |
    set -eu
    export DATABASE_URL="postgresql://umami:$(cat /run/secrets/db_password)@umami-db:5432/umami"
    export APP_SECRET="$(cat /run/secrets/app_secret)"
    exec npm run start-docker

Ergebnis: node_modules/.bin/next: not found. Das Umami-Image ist ein Next.js Standalone Build. Da gibt es kein node_modules/.bin/next. Der Standalone-Output hat nur server.js im Root.

Zweiter Versuch: exec node server.js statt npm run start-docker. Besser, aber dann fehlen die Prisma-Migrationen beim ersten Start.

Problem 2: Die Shell, die nicht lesen konnte

Dritter Versuch: Shell-Wrapper mit npx prisma migrate deploy vor dem Server-Start. Klingt vernünftig. Sieht auch vernünftig aus. Nur: nichts passierte. DATABASE_URL war leer, APP_SECRET war leer.

Das Problem: das Umami-Image läuft als User nextjs (UID 1001). Der hat offensichtlich keinen Zugriff auf /bin/sh in der erwarteten Weise, oder die Command-Substitution mit $(cat ...) funktioniert im Kontext des Alpine-basierten Node-Images nicht wie erwartet.

Die Lösung: den ganzen Entrypoint in Node schreiben. Kein Shell-Wrapper, direkt JavaScript:

entrypoint:
  - node
  - -e
  - |
    const { readFileSync } = require('fs');
    const { execSync } = require('child_process');
    const dbPass = readFileSync('/run/secrets/umami_db_password', 'utf8').trim();
    const appSecret = readFileSync('/run/secrets/umami_app_secret', 'utf8').trim();
    process.env.DATABASE_URL = 'postgresql://umami:' + encodeURIComponent(dbPass) + '@umami-db:5432/umami';
    process.env.APP_SECRET = appSecret;
    process.env.DISABLE_TELEMETRY = '1';
    execSync('npx prisma migrate deploy', { stdio: 'inherit', env: process.env });
    require('./server.js');

Node kann die Secrets lesen. Node kann die Umgebungsvariablen setzen. Node kann den Server starten. Keine Shell dazwischen, die irgendwas verschluckt.

Problem 3: Das Passwort mit den Sonderzeichen

Mein generiertes Datenbankpasswort: aB3x+Kp/7mZ9Qw+nR4s/tU2v=

Sieht sicher aus. Ist es auch. Aber es enthält +, / und =. Und die landen in einer PostgreSQL Connection URL. Unencoded. PostgreSQL auf der Server-Seite speichert das Passwort korrekt (über POSTGRES_PASSWORD_FILE). Aber Prisma auf der Client-Seite parst die URL und interpretiert + als Leerzeichen, / als Pfadtrenner.

Die Lösung: encodeURIComponent() im Node-Entrypoint. Deshalb steht da oben nicht einfach String-Interpolation, sondern:

process.env.DATABASE_URL = 'postgresql://umami:' + encodeURIComponent(dbPass) + '@umami-db:5432/umami';

Das macht aus + ein %2B, aus / ein %2F, aus = ein %3D. Prisma decodiert das korrekt, PostgreSQL bekommt das Original-Passwort. Beide Seiten sind glücklich.

Problem 4: Das Volume, das sich nichts sagen lässt

Selbst mit dem richtigen Passwort und der richtigen Encoding kam immer noch: FATAL: password authentication failed for user "umami".

Der Grund: PostgreSQL setzt POSTGRES_PASSWORD nur beim ersten Initialisieren des Data-Volumes. Wenn das Volume schon existiert (von einem früheren, fehlgeschlagenen Versuch), ignoriert PostgreSQL jede Passwort-Änderung komplett. Egal ob File, Umgebungsvariable oder Gebet.

Die einzige Lösung: Volume löschen, komplett frisch starten.

docker stop umami-db umami
docker rm umami-db umami
docker volume rm umami_umami-db-data

Das musste ich insgesamt vier Mal machen, bis alle anderen Probleme auch gelöst waren. Lektion: bei PostgreSQL-Passwort-Problemen immer zuerst das Volume prüfen.

Problem 5: PostgreSQL liest, Prisma trimmt (oder nicht)

Ein Detail, das mich eine weitere Runde gekostet hat: POSTGRES_PASSWORD_FILE im offiziellen PostgreSQL-Image strippt trailing Newlines. Wenn die Secret-Datei password\n enthält, speichert PostgreSQL password.

Auf der Umami-Seite liest readFileSync die Datei mit Newline. Ohne .trim() encoded encodeURIComponent dann auch das \n mit, was ein völlig anderes Passwort ergibt. Deswegen das .trim() auf beiden Seiten. Klein, aber entscheidend.

Das finale Compose-File

Nach all den Iterationen sieht das Ergebnis so aus:

services:
  umami-db:
    image: postgres:16-alpine
    container_name: umami-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD_FILE: /run/secrets/umami_db_password
    volumes:
      - umami-db-data:/var/lib/postgresql/data
    networks:
      - umami
    secrets:
      - umami_db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U umami"]
      interval: 10s
      timeout: 5s
      retries: 5

  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: umami
    restart: unless-stopped
    depends_on:
      umami-db:
        condition: service_healthy
    entrypoint:
      - node
      - -e
      - |
        const { readFileSync } = require('fs');
        const { execSync } = require('child_process');
        const dbPass = readFileSync('/run/secrets/umami_db_password', 'utf8').trim();
        const appSecret = readFileSync('/run/secrets/umami_app_secret', 'utf8').trim();
        process.env.DATABASE_URL = 'postgresql://umami:' + encodeURIComponent(dbPass) + '@umami-db:5432/umami';
        process.env.APP_SECRET = appSecret;
        process.env.DISABLE_TELEMETRY = '1';
        execSync('npx prisma migrate deploy', { stdio: 'inherit', env: process.env });
        require('./server.js');
    networks:
      - umami
      - apps
    secrets:
      - umami_db_password
      - umami_app_secret
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=apps"
      - "traefik.http.routers.umami.rule=Host(`umami.dieck-labs.de`)"
      - "traefik.http.routers.umami.entrypoints=web"
      - "traefik.http.routers.umami.middlewares=crowdsec@docker"
      - "traefik.http.services.umami.loadbalancer.server.port=3000"

Cloudflare Access: Das Dashboard schützen, das Tracking offen lassen

Mein gesamtes *.dieck-labs.de ist hinter Cloudflare Access. Heißt: ohne Login kommt niemand an umami.dieck-labs.de ran. Das ist gut fürs Dashboard, aber schlecht fürs Tracking. Denn die Besucher meiner Webseiten müssen script.js laden und Events an /api/send schicken können, ohne sich bei Cloudflare anzumelden.

Der Trick: eine separate Application in Cloudflare Access anlegen, die spezifischer ist als die Wildcard. Cloudflare matcht spezifischere Applications zuerst.

  1. Neue Self-hosted Application: “Umami Tracking”
  2. Zwei Application-Domains:
    • umami.dieck-labs.de mit Path /script.js
    • umami.dieck-labs.de mit Path /api/send
  3. Policy: Bypass mit Selector Everyone

Das Dashboard bleibt durch die Wildcard-Application geschützt. Nur die Tracking-Endpoints sind öffentlich. Elegante Lösung, kein Kompromiss bei der Sicherheit.

Einbindung in die Webseiten

Sobald Umami läuft (Default-Login: admin / umami, sofort ändern!), legt man eine Website an und bekommt eine ID. Dann ein Script-Tag in den Head:

<script defer src="https://umami.dieck-labs.de/getinfo.js" data-website-id="DEINE-WEBSITE-ID"></script>

Bei Astro kommt das in die Layout-Komponente. Fertig. Keine Cookies, kein Banner, keine DSGVO-Sorgen.

Bonus: Ad Blocker umgehen

Nach dem Deploy fiel mir auf: uBlock Origin blockiert das Tracking-Script. Der Standard-Pfad /script.js steht auf jeder großen Filterliste (EasyPrivacy und Co.). Logisch, ist ja ein generischer Analytics-Scriptname.

Umami hat dafür eine Lösung: die Umgebungsvariable TRACKER_SCRIPT_NAME. Setzt man die auf etwas Unauffälliges, liefert Umami den Tracker unter diesem Namen aus:

process.env.TRACKER_SCRIPT_NAME = 'getinfo';

Jetzt liegt das Script unter /getinfo.js statt /script.js. Ad Blocker melden nichts, weil der Pfad auf keiner Filterliste steht. Das Script selbst ist identisch, nur die URL ändert sich.

Nicht vergessen: die Cloudflare Access Bypass-Regel ebenfalls von /script.js auf /getinfo.js umstellen.

Fazit

Was ich als “zehn Minuten” eingeschätzt hatte, wurde zu einem Nachmittag Debugging. Die einzelnen Probleme waren alle klein: ein falscher Entrypoint hier, ein Sonderzeichen dort, ein persistentes Volume das sich nichts sagen lässt. Aber in Kombination hat es sich angefühlt wie ein Escape Room.

Die Kernlektionen:

  • PostgreSQL-Volumes merken sich das initiale Passwort. Immer löschen bei Passwort-Problemen.
  • Sonderzeichen in Passwörtern müssen URL-encoded werden, wenn sie in Connection-Strings landen.
  • Shell-Entrypoints in Node-Images sind tückisch. Im Zweifel: den Entrypoint direkt in Node schreiben.
  • Cloudflare Access und öffentliche APIs vertragen sich, wenn man die Priorität über separate Applications steuert.

Umami läuft jetzt stabil, verbraucht kaum Ressourcen und zeigt mir genau das, was ich wissen will: wer kommt woher, welche Seiten werden gelesen, wie lange bleiben die Leute. Ohne Google, ohne Cookies, ohne schlechtes Gewissen.