# Betrieb mit Dockge Der Stack wird über [Dockge](https://github.com/louislam/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 ```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: ```bash pg_restore -U afis -d afis --clean --if-exists /backups/daily/afis-.sql.gz ``` Dateien liegen im Volume `files`. Aus dem restic-Repository: ```bash 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: ```yaml 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: ```sql 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()` gibt die Sperre frei. **Nur die untätige beenden** — eine aktive gehört einem laufenden Versuch.