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>
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
- 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. - Keine Einmal-Container. Dockge zeigt beendete Container nicht dauerhaft an. Ein
Migrations- oder Backup-Job als
restart: nowäre unsichtbar, wenn er seit Wochen fehlschlägt. Migrationen laufen deshalb im Startkommando vonweb, Backups in Dauerläufern mit eigenem Cron. - 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.
- 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.
- 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
- In Dockge einen neuen Stack
afisanlegen compose.yamleinfügen,DEIN-USERersetzen- Im Reiter
.envdie Werte aus.env.exampleeintragen - Deploy. Beim ersten Start dauert
clamavmehrere Minuten, bis die Signaturen da sind. Solange bleibt der Container gelb, das ist normal. - Prüfen, dass
webgrün wird. Dannhttps://afis.example.deaufrufen.
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/healthprü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.