Files
AfiS-Aktensystem/docs/11-betrieb.md
T
HarlekinandClaude Opus 5 4446e3f0ac Fachdokumente nach docs/, Entscheidungen festgehalten
CLAUDE.md und README.md verweisen auf docs/, die Dateien lagen aber im
Wurzelverzeichnis. Verschoben.

docs/13-entscheidungen.md hält fest, wo die Dokumente sich widersprechen und
wie es aufgelöst wurde. Der wichtigste Punkt: 04-datenmodell.md verlangt eine
Regel, die jedes UPDATE auf CaseEntryVersion verwirft, 05-akten-und-vorlagen.md
verlangt, dass eine Umklassifizierung rückwirkend auf alle Versionen wirkt.
Beides zusammen geht nicht. Aufgelöst über einen Trigger, der die Tabelle
anhängend hält, aber das Anheben von minClearance zulässt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 00:00:58 +02:00

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