Files
betreuungs-cockpit/docker-compose.yml
T
Florian Wilde 4d826ef55f Dokumente mitsichern - eine Akte ohne ihre Dateien ist wertlos
Der Sicherungsdienst machte taeglich ein pg_dump und sonst nichts. Nach
einem Plattenschaden waere die Akte vollstaendig wiederhergestellt worden
- mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine einzige
Datei dahinter. Die Pruefsumme in der Akte haette dann ein Dokument
belegt, das es nicht mehr gibt. Gegenueber dem Betreuungsgericht ist das
der schlechteste denkbare Zustand.

Je Lauf entstehen jetzt zwei verschluesselte Dateien mit demselben
Zeitstempel: der Datenbankstand und ein Archiv des Ablageverzeichnisses.
Beide mit Pruefsumme, beide in der Aufbewahrung, beide im Monatsstand,
beide in der Selbstpruefung.

Schlaegt der Dokumententeil fehl, bricht der Lauf ab und raeumt den
angefangenen Datenbankstand wieder weg. Eine Sicherung, die nur die halbe
Wahrheit enthaelt, aber vollstaendig aussieht, ist schlimmer als gar
keine: Man verlaesst sich darauf und merkt es erst im Ernstfall.

## Abweichung von der urspruenglichen Idee

Im Issue stand "mc mirror statt rohem Verzeichnis". Beim Umsetzen zeigte
sich, dass der MinIO-Client in diesem Behaelter fehlt und bei jedem Lauf
aus dem Netz geholt werden muesste. Eine Internetabhaengigkeit im
Sicherungspfad ist genau das, was man dort nicht will.

Stattdessen wird das Verzeichnis direkt eingepackt. Das ist vertretbar,
weil MinIO ein Objekt erst vollstaendig unter .minio.sys/tmp schreibt und
dann an seinen Platz umbenennt: Ein laufender Upload liegt nie halbfertig
im Archiv, er fehlt hoechstens. Derselbe Weg deckt den Dateitreiber mit
ab.

## Nur lesend

Der Sicherungsdienst bekommt das Ablageverzeichnis mit ":ro" eingehaengt.
Er laeuft unbeaufsichtigt per cron; ein Fehler im Skript oder ein
kompromittierter Behaelter wuerde sonst Sicherung und Bestand auf einmal
treffen - genau der Fall, gegen den eine Sicherung da ist.

Deshalb schreibt die Wiederherstellung die Dateien auch nicht selbst an
ihren Platz, sondern packt sie nach ./backups aus und gibt den letzten
Handgriff als Einzeiler aus. In ein laufendes MinIO hineinzuschreiben
wuerde dessen Datenbestand ohnehin beschaedigen.

## Geprueft

Die Kernlogik einmal von Hand durchgespielt: packen, verschluesseln,
Selbstpruefung, zurueckholen - der Inhalt kam bitgleich zurueck, der
Klartext war im Archiv nicht auffindbar, und die Selbstpruefung schlug
sowohl bei einem beschaedigten Archiv als auch bei falschem Passwort an.

Dazu elf Pruefungen in tests/skripte.test.ts gegen vier Mutationen:
Dokumententeil entfernt, Abbruch entfernt, Aufraeumen halbiert,
Schreibrecht erteilt - jede wird rot.

Refs #41
2026-09-01 16:03:26 +02:00

190 lines
7.5 KiB
YAML

# =============================================================================
# Betreuungs-Cockpit - Produktivbetrieb
# =============================================================================
#
# docker compose up -d --build
#
# Kein Reverse Proxy in dieser Datei: der ist bereits vorhanden. Die
# Anwendung beendet selbst kein TLS und horcht standardmaessig nur auf
# 127.0.0.1:3000.
#
# Liegt der Proxy auf einem anderen Server, setze APP_BIND in der .env auf
# die Adresse, ueber die er herankommt - siehe docs/INSTALLATION.md,
# Schritt 7.
#
# Die Datenbank hat bewusst KEINE Portfreigabe. Sie ist nur ueber das
# interne Docker-Netz erreichbar. Wer mit psql heran muss, geht ueber
# docker compose exec db psql -U cockpit betreuungs_cockpit
# =============================================================================
name: betreuungs-cockpit
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-cockpit}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in der .env}
POSTGRES_DB: ${POSTGRES_DB:-betreuungs_cockpit}
# Deutsche Sortierung und Zeichensatz - wichtig fuer Namenslisten
# und fuer die Volltextsuche mit der Konfiguration "german".
POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=de_DE.UTF-8'
LANG: de_DE.UTF-8
volumes:
- postgres-daten:/var/lib/postgresql/data
- ./backups:/backups
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-cockpit} -d ${POSTGRES_DB:-betreuungs_cockpit}']
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks: [intern]
# Kein "ports:" - die Datenbank ist von aussen nicht erreichbar.
minio:
# S3-kompatibler Objektspeicher fuer die Dokumente. Wer einen externen
# Speicher in der EU nutzt (z.B. Hetzner Object Storage), kann diesen
# Dienst weglassen und in der .env S3_ENDPOINT darauf zeigen lassen.
# Auch dann gilt: AV-Vertrag nach Art. 28 DSGVO abschliessen.
image: minio/minio:RELEASE.2025-01-20T14-49-07Z
restart: unless-stopped
command: server /daten --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:?S3_ACCESS_KEY_ID fehlt in der .env}
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:?S3_SECRET_ACCESS_KEY fehlt in der .env}
MINIO_BROWSER_REDIRECT_URL: ${MINIO_KONSOLE_URL:-http://127.0.0.1:9001}
volumes:
- minio-daten:/daten
healthcheck:
test: ['CMD', 'mc', 'ready', 'local']
interval: 15s
timeout: 5s
retries: 10
start_period: 20s
ports:
# Nur an die Loopback-Adresse gebunden. Die Konsole erreichst du
# ueber einen SSH-Tunnel oder ueber deinen vorhandenen Proxy.
#
# Laeuft auf dem Server schon etwas auf 9001, weich in der .env aus:
# MINIO_KONSOLE_PORT=9011
- '${MINIO_KONSOLE_BIND:-127.0.0.1}:${MINIO_KONSOLE_PORT:-9001}:9001'
networks: [intern]
app:
build:
context: .
dockerfile: Dockerfile
args:
# In der Oberflaeche und unter /api/gesundheit sichtbar. Setzen mit:
# GIT_COMMIT=$(git rev-parse --short HEAD) docker compose up -d --build
GIT_COMMIT: ${GIT_COMMIT:-}
BUILD_ZEIT: ${BUILD_ZEIT:-}
restart: unless-stopped
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
environment:
NODE_ENV: production
DATABASE_URL: postgresql://${POSTGRES_USER:-cockpit}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-betreuungs_cockpit}?schema=public&connection_limit=10
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in der .env}
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
AUTH_TRUST_HOST: 'true'
ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar}
# Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt
# leere Schluesselvariablen - siehe src/lib/env.ts.
# ENC_KEY_V2: ${ENC_KEY_V2:-}
ENC_KEY_AKTIV: ${ENC_KEY_AKTIV:-1}
INDEX_KEY: ${INDEX_KEY:?INDEX_KEY fehlt in der .env}
SESSION_MAX_AGE: ${SESSION_MAX_AGE:-1800}
REAUTH_MAX_AGE: ${REAUTH_MAX_AGE:-300}
LOGIN_MAX_VERSUCHE: ${LOGIN_MAX_VERSUCHE:-5}
LOGIN_FENSTER_SEKUNDEN: ${LOGIN_FENSTER_SEKUNDEN:-900}
LOGIN_SPERRE_SEKUNDEN: ${LOGIN_SPERRE_SEKUNDEN:-900}
SPEICHER_TREIBER: s3
S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000}
S3_REGION: ${S3_REGION:-eu-central}
S3_BUCKET: ${S3_BUCKET:-betreuungs-cockpit}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-26214400}
OCR_AKTIV: ${OCR_AKTIV:-true}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-}
TZ: Europe/Berlin
ports:
# Standard ist die Loopback-Adresse: dann erreicht die Anwendung nur
# ein Proxy, der auf demselben Rechner laeuft.
#
# Liegt der Reverse Proxy auf einem ANDEREN Server, muss hier die
# Adresse stehen, ueber die er herankommt - am besten die eines
# privaten Netzes oder eines WireGuard-Tunnels:
#
# APP_BIND=10.0.0.5
#
# 0.0.0.0 nur setzen, wenn eine Firewall den Port ausschliesslich
# fuer die IP des Proxy-Servers oeffnet. Die Anwendung beendet kein
# TLS und gehoert nie unverschluesselt ins offene Netz.
- '${APP_BIND:-127.0.0.1}:${APP_PORT:-3000}:3000'
networks: [intern]
healthcheck:
test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:3000/api/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
sicherung:
# Taeglich um 03:15 Uhr ein verschluesseltes Backup nach ./backups.
# Legt zusaetzlich die Pruefsumme ab und raeumt alte Staende auf.
image: postgres:17-alpine
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PGHOST: db
PGUSER: ${POSTGRES_USER:-cockpit}
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB:-betreuungs_cockpit}
SICHERUNG_PASSWORT: ${SICHERUNG_PASSWORT:?SICHERUNG_PASSWORT fehlt in der .env}
SICHERUNG_AUFBEWAHRUNG_TAGE: ${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}
SICHERUNG_DOKUMENTE_QUELLE: /dokumente
TZ: Europe/Berlin
volumes:
- ./backups:/backups
# Die Dokumente, ausdruecklich NUR LESEND.
#
# Der Sicherungsdienst laeuft unbeaufsichtigt per cron. Ein Dienst,
# der die Daten zerstoeren kann, die er sichern soll, ist ein Risiko
# ohne Gegenwert - ein Fehler im Skript oder ein kompromittierter
# Behaelter wuerde beides auf einmal treffen.
#
# Zurueckspielen geschieht deshalb nicht hierher, sondern in ein
# Verzeichnis unter ./backups; siehe scripts/wiederherstellung.sh.
- minio-daten:/dokumente:ro
- ./scripts/sicherung.sh:/sicherung.sh:ro
- ./scripts/wiederherstellung.sh:/wiederherstellung.sh:ro
entrypoint: ['/bin/sh', '-c']
command:
- |
apk add --no-cache openssl coreutils >/dev/null
echo "15 3 * * * /bin/sh /sicherung.sh >> /backups/sicherung.log 2>&1" > /etc/crontabs/root
echo "Sicherungsdienst bereit. Naechster Lauf 03:15 Uhr."
crond -f -l 8
networks: [intern]
volumes:
postgres-daten:
minio-daten:
networks:
intern:
driver: bridge