Files
AfiS-Aktensystem/docs/11-betrieb.md
T
HarlekinandClaude Opus 5 73b6dc820e Suchindex ins Schema, Testdatenbank je Lauf frisch
Zwei Funde beim Nachsehen, warum `pnpm db:migrate` hängt.

**Der Befehl hing nicht, er fragte.** `prisma migrate dev` fand einen
Unterschied zwischen schema.prisma und den Migrationen und wartete auf einen
Namen für eine neue Migration. Deren Inhalt wäre gewesen, den GIN-Index über
den Suchvektor zu löschen: er stand nur in der Migration, weil
Unsupported("tsvector") dazu verleitet, den Index für nicht darstellbar zu
halten. Er ist darstellbar. Wer die Frage in Eile beantwortet hätte, hätte die
gestufte Volltextsuche verloren und es erst gemerkt, wenn die Suche leer
bleibt.

packages/db/tests/schema-drift.test.ts prüft das jetzt und nennt im Fehlerfall
das fehlende Stück. Gegengeprobt: ohne die Zeile im Schema schlägt der Test an.

Die Sperre, an der der zweite Versuch scheiterte (P1002), hielt eine verwaiste
Verbindung eines abgeschossenen migrate-dev. Wie man den Halter findet und
freigibt, steht jetzt in docs/11.

**Die Testdatenbank wird verworfen und neu angelegt.** Weil Akten nicht
löschbar sind, hatte sie 2487 angesammelt, und drei Zusicherungen auf eine auf
zehn Einträge begrenzte Liste fanden ihre eigene Akte nicht mehr darin. Das
sieht aus wie ein Fehler im Code und ist keiner. Neu aufsetzen kostet
dreieinhalb Sekunden; KEEP_TEST_DB=1 behält den Bestand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:13:34 +02:00

9.4 KiB

Betrieb mit Dockge

Der Stack wird über Dockge verwaltet, nicht über die Kommandozeile. Das bestimmt ein paar Entscheidungen, die sonst anders aussähen.

Was daraus folgt

  1. Ein einziger Stack, eine compose.yaml. Dockge verwaltet Stacks getrennt und legt für jeden ein eigenes Netzwerk an. Mehrere Stacks müsste man von Hand zusammenstecken, also kommt alles in einen.
  2. Keine Einmal-Container. Dockge zeigt beendete Container nicht dauerhaft an. Ein Migrations- oder Backup-Job als restart: no wäre unsichtbar, wenn er seit Wochen fehlschlägt. Migrationen laufen deshalb im Startkommando von web, Backups in Dauerläufern mit eigenem Cron.
  3. Healthchecks sind die Statusanzeige. Dockge zeigt sie direkt in der Übersicht. Jeder Dienst braucht einen, sonst siehst du nur „läuft" und weißt nichts.
  4. Named Volumes statt Bind Mounts. Überstehen Stack-Updates zuverlässig und tauchen in Dockge sauber auf. Ausnahme ist das Backup-Ziel, das gehört auf eine echte Platte.
  5. Kein Caddy. Der Cloudflare Tunnel übernimmt TLS und Kompression, und Next.js liefert seine statischen Dateien selbst mit korrekten Cache-Headern aus. Ein zusätzlicher Proxy wäre nur ein weiterer Container, der kaputtgehen kann.

Ablage

/opt/stacks/afis/
├── compose.yaml     im Dockge-Editor bearbeitbar
└── .env             eigener Reiter in Dockge

Beides wird über die Weboberfläche gepflegt. .env gehört nicht ins Git-Repository, die Vorlage dafür ist .env.example.

compose.yaml

services:
  web:
    image: ghcr.io/DEIN-USER/afis-web:latest
    restart: unless-stopped
    command: sh -c "npx prisma migrate deploy && node server.js"
    env_file: .env
    environment:
      NODE_ENV: production
      PORT: "3000"
    volumes:
      - files:/data/files
      - clamav_socket:/var/run/clamav
    ports:
      - "3000:3000"          # nur im LAN, von außen kommt der Tunnel
    depends_on:
      pgbouncer: { condition: service_healthy }
      clamav:    { condition: service_healthy }
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 40s

  worker:
    image: ghcr.io/DEIN-USER/afis-worker:latest
    restart: unless-stopped
    env_file: .env
    depends_on:
      pgbouncer: { condition: service_healthy }
    healthcheck:
      test: ["CMD", "node", "healthcheck.js"]   # prüft Gateway-Verbindung
      interval: 60s
      timeout: 10s
      retries: 3
      start_period: 30s

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: afis
      POSTGRES_USER: afis
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      TZ: ${TZ}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U afis -d afis"]
      interval: 10s
      timeout: 5s
      retries: 5

  pgbouncer:
    image: edoburu/pgbouncer:latest
    restart: unless-stopped
    environment:
      DB_HOST: db
      DB_USER: afis
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_NAME: afis
      POOL_MODE: transaction
      MAX_CLIENT_CONN: "100"
      DEFAULT_POOL_SIZE: "20"
      AUTH_TYPE: scram-sha-256
    depends_on:
      db: { condition: service_healthy }
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 6432 -U afis"]
      interval: 15s
      timeout: 5s
      retries: 5

  clamav:
    image: clamav/clamav:stable
    restart: unless-stopped
    environment:
      CLAMAV_NO_MILTERD: "true"
      FRESHCLAM_CHECKS: "2"
    volumes:
      - clamav_db:/var/lib/clamav
      - clamav_socket:/var/run/clamav
      - files:/data/files
    healthcheck:
      test: ["CMD-SHELL", "clamdscan --ping 1 || exit 1"]
      interval: 60s
      timeout: 15s
      retries: 3
      start_period: 300s     # erster Signaturdownload dauert

  pdf:
    image: ghcr.io/DEIN-USER/afis-pdf:latest    # Playwright-Basis
    restart: unless-stopped
    environment:
      PORT: "3000"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 60s
      timeout: 10s
      retries: 3
      start_period: 30s

  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
    depends_on:
      web: { condition: service_healthy }

  db-backup:
    image: prodrigestivill/postgres-backup-local:16
    restart: unless-stopped
    environment:
      POSTGRES_HOST: db
      POSTGRES_DB: afis
      POSTGRES_USER: afis
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_EXTRA_OPTS: "-Fc"
      SCHEDULE: "0 15 4 * * *"        # täglich 04:15
      BACKUP_KEEP_DAYS: "7"
      BACKUP_KEEP_WEEKS: "4"
      BACKUP_KEEP_MONTHS: "6"
      HEALTHCHECK_PORT: "8080"
      TZ: ${TZ}
    volumes:
      - /mnt/backup/afis/db:/backups
    depends_on:
      db: { condition: service_healthy }

  offsite-backup:
    image: mazzolino/restic:latest
    restart: unless-stopped
    environment:
      RUN_ON_STARTUP: "false"
      BACKUP_CRON: "0 30 4 * * *"     # 15 Minuten nach dem Dump
      RESTIC_REPOSITORY: ${RESTIC_REPOSITORY}
      RESTIC_PASSWORD: ${RESTIC_PASSWORD}
      RESTIC_BACKUP_SOURCES: /data/db /data/files
      RESTIC_FORGET_ARGS: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune"
      TZ: ${TZ}
    volumes:
      - /mnt/backup/afis/db:/data/db:ro
      - files:/data/files:ro

volumes:
  pgdata:
  files:
  clamav_db:
  clamav_socket:

DEIN-USER durch deinen GitHub-Namen ersetzen. Die Images baut ein GitHub-Actions-Workflow und schiebt sie in die GitHub Container Registry. Dockge zieht sie beim Update.

Erste Einrichtung

  1. In Dockge einen neuen Stack afis anlegen
  2. compose.yaml einfügen, DEIN-USER ersetzen
  3. Im Reiter .env die Werte aus .env.example eintragen
  4. Deploy. Beim ersten Start dauert clamav mehrere Minuten, bis die Signaturen da sind. Solange bleibt der Container gelb, das ist normal.
  5. Prüfen, dass web grün wird. Dann https://afis.example.de aufrufen.

Aktualisieren

Dockge: Stack öffnen, „Update", fertig. Das zieht neue Images und startet neu. Die Migration läuft automatisch im Startkommando von web.

Vor jedem Update ein Dump. Der läuft zwar nachts, aber ein Update mittags nach einer kaputten Migration ohne frisches Backup ist genau der Fall, den man einmal erlebt. In der Dockge-Konsole des db-backup-Containers:

/backup.sh

Wiederherstellung

Aus der Dockge-Konsole des db-Containers:

pg_restore -U afis -d afis --clean --if-exists /backups/daily/afis-<datum>.sql.gz

Dateien liegen im Volume files. Aus dem restic-Repository:

restic restore latest --target /restore --include /data/files

Einmal im Monat wirklich durchspielen, in eine Wegwerf-Datenbank. Ein ungetestetes Backup ist keins, und das ist der Punkt, an dem Homeserver-Projekte reihenweise scheitern.

Was nicht nach außen darf

Nur web hat eine Portfreigabe, und die geht ins LAN. db, pgbouncer, clamav und pdf sind ausschließlich im Stack-Netz erreichbar. Am Router wird kein Port geöffnet, der Tunnel verbindet sich ausgehend.

Der Dockge-Zugang selbst (Port 5001) gehört ebenfalls nicht ins Internet. Wenn du von unterwegs dran willst: über Tailscale oder einen zweiten, per Cloudflare Access geschützten Tunnel, nie offen.

Beobachtung

  • Dockge-Übersicht als erste Anlaufstelle: alle Healthchecks auf einen Blick
  • /api/health prüft Datenbank, Discord-Gateway und ClamAV und wird vom Healthcheck genutzt
  • Der Worker meldet Start, Verbindungsabbruch und fehlgeschlagenen Voll-Abgleich in den Admin-Discord-Kanal. Das ist deine eigentliche Alarmierung, weil du Discord ohnehin offen hast.
  • Logs über die Dockge-Oberfläche. Zusätzlich im Compose begrenzen, sonst läuft die Platte voll:
x-logging: &logging
  logging:
    driver: json-file
    options: { max-size: "10m", max-file: "3" }

Als Anker <<: *logging bei jedem Dienst einhängen.

Aufräumen

Tägliche Aufgabe in der Anwendung, kein eigener Container: abgelaufene Sessions löschen, Quarantäne-Dateien älter als 24 Stunden löschen, PDF-Cache älter als 30 Tage löschen. Der Audit-Log wird nie gelöscht.

Wenn eine Migration hängt

prisma migrate dev ist interaktiv. Findet es einen Unterschied zwischen schema.prisma und den Migrationen, fragt es nach einem Namen für eine neue Migration und wartet — ohne Terminal sieht das aus wie ein Aufhänger. pnpm exec prisma migrate status sagt nicht-interaktiv, woran man ist, migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script zeigt den Unterschied. Im Betrieb läuft ohnehin migrate deploy, das fragt nie.

Wird ein laufendes migrate dev abgeschossen, bleibt seine Datenbankverbindung offen und hält die Sperre, mit der Prisma parallele Migrationen verhindert. Der nächste Versuch läuft dann in P1002: Timed out trying to acquire a postgres advisory lock. Den Halter findet man so:

SELECT l.pid, a.state, now() - a.state_change AS untaetig_seit, a.query
  FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid
 WHERE l.locktype = 'advisory';

Die verwaiste Verbindung erkennt man an state = idle und einer Untätigkeit, die zum Zeitpunkt des Abbruchs passt. SELECT pg_terminate_backend(<pid>) gibt die Sperre frei. Nur die untätige beenden — eine aktive gehört einem laufenden Versuch.