Deploying.de - dsblank/gramps-connect GitHub Wiki

🌐 English · Español · Français · Nederlands · 简体中文

Bereitstellung

deploy/ ist die echte Mehrbenutzer-Form von Gramps Connect: ein containerisiertes app/-Frontend + gramps-web-api- Backend, unterlegt mit echtem Postgres (über das SharedPostgreSQL- Add-on), vorgeschaltet mit Caddy für TLS, gedacht dafür, wirklich irgendwo gehostet zu werden — echte Geheimnisse, eine echte Domain/ein echtes Zertifikat und mehrere Benutzer mit jeweils eigenem Login. Es ist außerdem der einzige Weg, Live-Zusammenarbeit in Aktion zu sehen; die eigenständige Desktop-Version ist absichtlich für einen einzelnen Benutzer ausgelegt, es gibt dort also niemand sonst, dessen Änderungen man beim Erscheinen zusehen könnte.

Das Backend ist das offizielle, unveränderte dmstraub/gramps-webapi- Image — dasselbe, das die eigene CI von gramps-project/gramps-web-api bei jedem Release veröffentlicht, und auf dem gramps-project/gramps-web selbst aufbaut — kein von diesem Repository selbst aus dem Quellcode gebauter Build. Das hält diese Bereitstellung eine schlichte, standardmäßige gramps-web-api-Instanz, mit der jeder kompatible Client sprechen kann, nicht nur das eigene Frontend von Gramps Connect. Die Add-ons SharedPostgreSQL/PostgreSQL/FilterRules/JSON, Mehrstammbaum-Unterstützung und kompilierte Übersetzungen stammen alle bereits aus diesem Image; das Einzige, was deploy/Dockerfile hinzufügt, ist das Frontend von app/, obenauf als statische Dateien geschichtet. Der Kompromiss: dieses Upstream-Image basiert auf gramps-web-base (~4,3 GB — torch, sentence-transformers, opencv, 45 Tesseract-Sprachpakete) mit bedingungslos installierten KI-Extras, da es keine offizielle schlanke Variante zum Ziehen gibt.

Frontend und Backend teilen sich standardmäßig einen Container/Ursprung (gramps_webapi liefert die gebaute SPA über STATIC_PATH aus und behandelt /api/* im selben Prozess), Gramps Connect selbst braucht also keine CORS-Konfiguration — das ist nur relevant für ein anderes, separat gehostetes Frontend, das von außen hereinruft (in diesem Fall GRAMPSWEB_CORS_ORIGINS in deploy/.env setzen).

Dienste: caddy (TLS-Terminierung, der einzige veröffentlichte Einstiegspunkt), app (gunicorn, liefert das Frontend + /api/* aus), worker (Celery, führt Import-/Medien-/Suchindex-Neuaufbau-Jobs aus), postgres (Stammbaumdaten über SharedPostgreSQL), redis (Celery-Broker).

Lokal ausführen

cp deploy/.env.example deploy/.env    # dann bearbeiten -- siehe die Kommentare in der Datei
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build

Dann https://localhost aufrufen (Caddy stellt sich automatisch selbst ein lokales, selbstsigniertes Zertifikat aus — die Browser-Warnung akzeptieren).

Auf einem echten Host ausführen

Einmalig auf GitHubs Runnern bauen statt auf dem Host (gh workflow run build-docker.yml, oder im Actions-Tab auslösen), was ghcr.io/<owner>/gramps-connect:latest veröffentlicht, dann auf dem Host:

cp deploy/.env.example deploy/.env    # diesmal mit echten Geheimnissen/Domain
docker compose -f deploy/docker-compose.yml --env-file deploy/.env pull
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d

Eine Domain auf den Host zeigen lassen und deploy/Caddyfile bearbeiten (siehe TLS unten), um statt des selbstsignierten ein echtes Zertifikat zu bekommen.

Beispiel: Hosting auf einem DigitalOcean-Droplet

Eine Schritt-für-Schritt-Anleitung von einem leeren Konto bis zu einer laufenden Website auf einem kleinen Cloud-Server (DigitalOcean nennt diese „Droplets“). Jeder andere VPS-Anbieter funktioniert genauso, sobald man einen Linux-Server mit Docker hat.

1. Das Droplet anlegen

In der DigitalOcean-Konsole Create → Droplets:

  • Image: Marketplace → Docker on Ubuntu (Docker vorinstalliert).
  • Größe: Basic, 1 vCPU / 2 GB RAM (s-1vcpu-2gb) ist die kleinste Größe, die gut funktioniert. Das Backend-Image ist groß (~4 GB, siehe oben), also nicht kleiner wählen.
  • Region: die, die Ihren Benutzern am nächsten liegt.
  • Authentifizierung: ein SSH-Schlüssel (sicherer als ein Passwort).

Dann Networking → Firewalls → Create Firewall: eingehend nur 22, 80 und 443 erlauben und die Firewall dem Droplet zuweisen.

2. Swap hinzufügen

2 GB RAM sind bei Importen und beim Start knapp, also eine 2 GB große Swap-Datei anlegen (per SSH als root angemeldet):

fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

3. Eine Domain darauf zeigen lassen

Optional, aber empfohlen, denn nur so bekommt man ein echtes HTTPS-Zertifikat. Ein kostenloser DuckDNS-Name funktioniert, ebenso ein A-Eintrag bei einer eigenen Domain. In beiden Fällen auf die öffentliche IP des Droplets zeigen lassen.

4. Gramps Connect installieren und starten

git clone https://github.com/dsblank/gramps-connect /opt/gramps-connect
cd /opt/gramps-connect/deploy
cp .env.example .env
# .env bearbeiten: GRAMPSWEB_ADMIN_USER / GRAMPSWEB_ADMIN_PASSWORD,
# PUBLIC_URL=https://ihre.domain und die E-Mail-Einstellungen (siehe unten)
docker compose pull
docker compose up -d

Damit wird das vorgebaute, öffentliche Image ghcr.io/dsblank/gramps-connect:latest gezogen; auf dem Droplet selbst wird nichts gebaut.

5. HTTPS einschalten

deploy/Caddyfile wie unter TLS beschrieben bearbeiten: den Block :443 { tls internal ... } durch ihre.domain { reverse_proxy app:5000 } ersetzen und den :80-Block löschen, dann erneut docker compose up -d. Caddy beantragt und erneuert ein Let's-Encrypt-Zertifikat selbstständig.

6. Den ersten Stammbaum anlegen

Den Abschnitten Ersteinrichtung und Daten importieren unten folgen, dabei https://ihre.domain statt https://localhost verwenden.

E-Mail (Passwort-Zurücksetzen, Einladungen)

  • Neue DigitalOcean-Konten blockieren ausgehendes SMTP. Einen Relay auf Port 587 verwenden (z. B. Brevo, SendGrid, Mailgun) und, falls trotzdem keine E-Mails ankommen, ein Support-Ticket bei DigitalOcean (Kategorie Networking) eröffnen, um die Sperre aufheben zu lassen.
  • GRAMPSWEB_EMAIL_USE_STARTTLS muss kleingeschrieben true sein. True wird stillschweigend als Zeichenkette gelesen, und die Verbindung schlägt fehl.
  • Der SMTP-Login bei Brevo ist eine generierte Adresse (etwa [email protected]), nicht die E-Mail-Adresse, mit der man sich registriert hat.

Später aktualisieren

Siehe Einen gehosteten Server aktualisieren unten.

Einen gehosteten Server aktualisieren

Diese Schritte gelten für jeden Host, auf dem der deploy/-Stack läuft, ob Droplet oder nicht. Die Befehle gehen davon aus, dass das Repository nach /opt/gramps-connect geklont ist und man sich in dessen deploy/-Verzeichnis befindet.

1. Sichern

cd /opt/gramps-connect/deploy
# Stammbaumdaten (Postgres)
docker compose exec -T postgres sh -c 'pg_dumpall -U "$POSTGRES_USER"' > backup-$(date +%F).sql
# Benutzerkonten (sqlite) und Mediendateien
docker compose cp app:/app/users  ./backup-users-$(date +%F)
docker compose cp app:/app/media  ./backup-media-$(date +%F)

Updates können beim Start Datenbankmigrationen ausführen, die sich nicht rückgängig machen lassen; eine Sicherung ist daher die einzige Möglichkeit, ein Update vollständig zurückzunehmen.

2. Sicherstellen, dass das neue Image gebaut ist

Der Server zieht ghcr.io/dsblank/gramps-connect:latest, das sich nur ändert, wenn der Workflow build-docker.yml läuft. Direkt nachdem ein Release getaggt wurde, vor dem Update prüfen, ob der Docker-Build fertig ist (im Actions-Tab des Repositorys), sonst zieht man die vorherige Version.

3. Aktualisieren

cd /opt/gramps-connect && git pull       # aktualisierte deploy/-Konfiguration
cd deploy
docker compose pull app                  # das neue Image
docker compose up -d                     # Geändertes neu erzeugen

Nur app und worker werden neu erzeugt. postgres, redis und caddy laufen weiter, und alle Daten bleiben in Docker-Volumes. Niemals -v an docker compose down anhängen, außer man will wirklich alles löschen (siehe Anmerkungen).

Falls git pull sich weigert wegen lokaler Änderungen: ein Host mit echter Domain hat eine bearbeitete deploy/Caddyfile (siehe TLS), die nie committet wird, sodass ein Update, das die Kopie im Repository ändert, damit kollidiert. Die eigenen Änderungen behält man so:

git stash && git pull && git stash pop    # dann einen eventuellen Konflikt in deploy/Caddyfile beheben

deploy/.env wird von git ignoriert und ist daher nie betroffen.

4. Auf neue Einstellungen prüfen

diff .env.example .env

Neu hinzugekommene Einstellungen aus .env.example in .env übernehmen, dann erneut docker compose up -d ausführen, falls man etwas geändert hat.

5. Überprüfen

docker compose ps                  # app und worker frisch erzeugt, "Up"
docker compose logs --tail 50 app  # gunicorn-Zeilen "Booting worker", keine Tracebacks

Dann die Website laden und sich anmelden. Eine Zeile Gtk-CRITICAL ... gtk_icon_theme_get_for_screen pro gunicorn-Worker ist normal und harmlos: auf dem Server ist GTK installiert, aber es gibt kein Display.

Zurückrollen

Jedes Image ist zusätzlich mit dem Commit getaggt, aus dem es gebaut wurde, ghcr.io/dsblank/gramps-connect:<commit-sha> (die Liste steht auf der Packages-Seite des Repositorys). Um zurückzugehen, die image:-Zeile unter x-app-image oben in deploy/docker-compose.yml auf dieses Tag ändern, dann:

docker compose up -d

Hat die neuere Version die Datenbank migriert, zusätzlich die Sicherung aus Schritt 1 wiederherstellen.

Einen eigenen Fork betreiben

Ein Fork veröffentlicht sein eigenes Image. build-docker.yml im eigenen Fork ausführen (veröffentlicht ghcr.io/<ihr-benutzer>/gramps-connect) und die image:-Zeile oben darauf setzen, oder auf dem Server mit docker compose up -d --build bauen. Das Bauen auf dem Server braucht mehr Speicherplatz und Arbeitsspeicher als das 2-GB-Droplet oben, da allein das Basis-Image etwa 4 GB groß ist.

Docker-Befehle

Alle folgenden Befehle setzen voraus, im Wurzelverzeichnis des Repositorys zu sein. docker compose ist die Plugin-Form; hat die eigene Docker-Installation nur das eigenständige Binary, stattdessen docker-compose verwenden (gleiche Optionen so oder so).

# Status aller fünf Dienste
docker compose -f deploy/docker-compose.yml ps

# Logs (-f zum Mitverfolgen hinzufügen, --tail=100 zum Begrenzen)
docker compose -f deploy/docker-compose.yml logs app
docker compose -f deploy/docker-compose.yml logs worker

# Einen Dienst neu starten (z. B. nach einer Änderung in deploy/.env)
docker compose -f deploy/docker-compose.yml up -d
# ^ erzeugt jeden Dienst neu, dessen Konfiguration (Image, Umgebung, Volumes)
#   sich geändert hat; zuerst --build hinzufügen, wenn deploy/Dockerfile
#   oder der Backend-Quellcode in app/ bearbeitet wurde.

# Alles stoppen, Daten behalten (Volumes bleiben erhalten)
docker compose -f deploy/docker-compose.yml down

# Stoppen und alle Daten löschen (Vorsicht -- löscht Postgres, Medien, Benutzer usw.)
docker compose -f deploy/docker-compose.yml down -v

# In einen laufenden Container hineinshellen
docker compose -f deploy/docker-compose.yml exec app sh

Anmeldedaten: diese Bereitstellung vs. die eigenständige Version

Diese Docker-Bereitstellung hat kein Standardpasswort — man setzt GRAMPSWEB_ADMIN_USER/GRAMPSWEB_ADMIN_PASSWORD selbst in deploy/.env vor dem ersten Start, und genau das sät der Entrypoint ein. Nichts erzeugt oder gibt ein Passwort für einen aus.

Das unterscheidet sich von gramps-connect-desktop (der Einzelbenutzer-PyInstaller-Demoversion, unabhängig von dieser Docker-Bereitstellung): dort wird immer ein festes Konto admin/admin eingesät, was dort in Ordnung ist, da es eine wegwerfbare lokale Demo ist, nichts, was auf einem echten Server exponiert wird.

Ersteinrichtung: der eingesäte Admin

Beim ersten Start (wenn die Volumes app-users/app-db erstmals leer sind) macht der Entrypoint Folgendes:

  • erzeugt und speichert einen Flask-Geheimschlüssel dauerhaft, falls GRAMPSWEB_SECRET_KEY leer gelassen wurde
  • führt Migrationen der Benutzerdatenbank aus
  • sät einen Site-Admin-Benutzer (stammbaumlos, Rolle 5) aus GRAMPSWEB_ADMIN_USER / GRAMPSWEB_ADMIN_PASSWORD (beide in deploy/.env erforderlich — kein admin/admin-Rückfall)

Ein stammbaumloser Site-Admin kann Stammbäume über die API erstellen/ auflisten/löschen, kann aber — da das Frontend von app/ keine Stammbaum-Auswahl-Oberfläche hat und immer erwartet, dass das JWT des angemeldeten Benutzers bereits einen Stammbaum trägt — die Daten eines Stammbaums erst durchsuchen, sobald einem zugewiesen. Es gibt keinen CLI-Befehl zum Anlegen eines Stammbaums im Mehrstammbaum-Modus, den ersten Stammbaum also einmalig über die API anlegen:

TOKEN=$(curl -sk -X POST https://localhost/api/token/ \
  -H 'Content-Type: application/json' \
  -d '{"username":"<admin>","password":"<admin-Passwort>"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")

TREE_ID=$(curl -sk -X POST https://localhost/api/trees/ \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"Mein Familienstammbaum"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")

echo "$TREE_ID"

Dann entweder den Site-Admin selbst diesem Stammbaum zuweisen, oder (empfohlen — hält Site-Verwaltung und Stammbaum-Eigentümerschaft getrennt) stattdessen einen eigenen, stammbaumgebundenen Benutzer anlegen:

# Option A: den bestehenden Site-Admin dem Stammbaum zuweisen.
curl -sk -X PUT "https://localhost/api/users/<admin>/" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"tree\":\"$TREE_ID\"}"

# Option B: stattdessen einen separaten gewöhnlichen, an den Stammbaum
# gebundenen Benutzer anlegen. email und full_name sind vom Schema
# vorgeschrieben, auch wenn ungenutzt.
curl -sk -X POST "https://localhost/api/users/<username>/" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"email\":\"<email>\",\"full_name\":\"<vollständiger Name>\",\"password\":\"<Passwort>\",\"role\":4,\"tree\":\"$TREE_ID\"}"

role ist eine Ganzzahl (gramps_webapi.auth.const):

Rolle Wert Anmerkungen
ADMIN 5 Site-Admin; einzige Rolle, die stammbaumlos sein darf
OWNER 4 Volle Kontrolle über den eigenen Stammbaum
EDITOR 3 Kann Stammbaumdaten bearbeiten
CONTRIBUTOR 2 Kann Daten hinzufügen
MEMBER 1 Lesezugriff
GUEST 0 Minimaler Lesezugriff

Dann unter https://localhost/ mit dem gerade angelegten Benutzer anmelden — bereits vor einer Stammbaum-Zuweisung ausgestellte Tokens tragen sie nicht, also bei einer wiederverwendeten, bereits offenen Sitzung ab- und wieder anmelden.

Daten importieren (z. B. example.gramps, mit Medien)

TOKEN=$(curl -sk -X POST https://localhost/api/token/ \
  -H 'Content-Type: application/json' \
  -d '{"username":"<owner>","password":"<Passwort>"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")

# 1. Die .gramps-XML-Datei importieren. WICHTIG: dieser Endpunkt liest den
#    rohen Request-Body und schreibt ihn direkt auf die Platte -- er
#    parst KEIN multipart/form-data, also --data-binary verwenden, nicht
#    curls -F/--form (ein multipart-Upload beschädigt die Datei
#    stillschweigend mit Boundary-/Header-Bytes, und der Importer von
#    Gramps schlägt dann mit einem generischen, nicht hilfreichen
#    "Import failed" ohne weitere Details fehl).
curl -sk -X POST https://localhost/api/importers/gramps/file \
  -H "Authorization: Bearer $TOKEN" --data-binary @example.gramps

# Die Antwort ist ein Task-Handle (der Import läuft über den worker-Dienst
# via Celery) -- ihn abfragen, bis "state":"SUCCESS" (oder "FAILURE"):
curl -sk https://localhost/api/tasks/<task_id> -H "Authorization: Bearer $TOKEN"

# 2. Medien: die referenzierten Mediendateien in ein Zip packen (jede
#    Teilmenge/Übermenge ist in Ordnung -- Dateien werden per Prüfsumme
#    zugeordnet, nicht referenzierte werden ignoriert) und das Archiv
#    hochladen, wieder als roher Body:
curl -sk -X POST https://localhost/api/media/archive/upload/zip \
  -H "Authorization: Bearer $TOKEN" --data-binary @media.zip

# 3. Prüfen: Objektzahlen für den Stammbaum des angemeldeten Benutzers.
curl -sk https://localhost/api/metadata/ -H "Authorization: Bearer $TOKEN" \
  | python3 -m json.tool

Bekannte Falle: mit GRAMPSWEB_MEDIA_PREFIX_TREE=True (in dieser Compose-Datei standardmäßig gesetzt) werden Medien unter MEDIA_BASE_DIR/<tree_id>/ gespeichert, aber nichts legt dieses Unterverzeichnis pro Stammbaum automatisch an — weder POST /api/trees/ noch der Medien-Archiv-Upload-Task. Das Hochladen von Medien für einen Stammbaum schlägt mit Directory /app/media/<tree_id> does not exist fehl, bis es einmalig angelegt wird:

docker compose -f deploy/docker-compose.yml exec app mkdir -p /app/media/<tree_id>

Zeitüberschreitung bei großen Medien-Uploads: sowohl der Einzeldatei- (POST /api/media/) als auch der ZIP-Archiv-Upload- Endpunkt (POST /api/media/archive/upload/zip) lesen den Request-Body synchron innerhalb des gunicorn-Workers von app, bevor an Celery übergeben wird, sodass eine große Datei oder eine langsame Verbindung das Pro-Anfrage-Zeitlimit von gunicorn überdauern kann — der Browser sieht dann einen nackten Internal Server Error ohne JSON-Body (von gunicorn/Caddy, nicht von Gramps). deploy/docker-compose.yml setzt GUNICORN_TIMEOUT standardmäßig auf 600s; bei weiterhin fehlschlagenden Uploads über GUNICORN_TIMEOUT in deploy/.env weiter erhöhen (siehe die Kommentare in dieser Datei).

TLS

caddy ist der einzige Dienst mit veröffentlichten Ports (80/443) und terminiert TLS vor app. Ohne konfigurierte Domain erzeugt und speichert es automatisch eine eigene lokale CA und stellt daraus ein selbstsigniertes Zertifikat aus (tls internal in deploy/Caddyfile) — Browser zeigen beim ersten Mal eine Vertrauenswarnung; zum Fortfahren akzeptieren (oder die erzeugte CA zum System-/Browser-Vertrauensspeicher hinzufügen, wenn die Warnung ohne echte Domain verschwinden soll). Port 80 leitet auf 443 um.

Sobald eine echte Domain auf diesen Server zeigt, deploy/Caddyfile bearbeiten: den Block :443 { tls internal ... } durch example.com { reverse_proxy app:5000 } ersetzen und den :80-Block löschen — Caddy übernimmt ACME-Ausstellung, Erneuerung und die Port-80-Umleitung für eine echte Domain automatisch, keine tls-Direktive nötig. Außerdem PUBLIC_URL in deploy/.env auf die echte https://-Domain aktualisieren, dann docker compose -f deploy/docker-compose.yml up -d, um beide Änderungen zu übernehmen.

gramps-web neben Gramps Connect ausführen

Standardmäßig nicht eingebunden (kein grampsweb-Dienst in docker-compose.yml), aber gut zu wissen: da das Backend eine schlichte, unveränderte gramps-web-api-Instanz ist, kann gramps-project/gramps-web (das andere offizielle Gramps-Frontend) auch gegen dasselbe Backend laufen — dieselben Daten, dieselben Stammbäume/Benutzer, nur eine andere Oberfläche auf einem anderen Port.

gramps-web veröffentlicht ghcr.io/gramps-project/grampsjs:latest genau dafür: nginx, das nur den eigenen statischen Build ausliefert, kein eingebackenes Backend. Dessen nginx-Konfiguration leitet /api per Reverse-Proxy an eine Umgebungsvariable API_HOST beim Container-Start weiter, aus Sicht des Browsers ist es also same-origin — kein GRAMPSWEB_CORS_ORIGINS für diesen Weg speziell nötig (diese Einstellung ist für eine ganz woanders gehostete gramps-web-Instanz, die stattdessen cross-origin hereinruft, statt über diesen Proxy).

Um es hinzuzufügen, ein docker-compose.yml-Dienst in dieser Art:

grampsweb:
  image: ghcr.io/gramps-project/grampsjs:latest
  environment:
    API_HOST: http://app:5000
    # Dockers eingebetteter DNS-Resolver -- die resolver-Direktive von
    # default.conf.template erfordert, dass dies explizit gesetzt wird.
    NAME_SERVER: 127.0.0.11
  depends_on:
    - app
  restart: unless-stopped

auf einem eigenen Port veröffentlicht (entweder direkt, z. B. ports: ["8081:80"], oder vor Caddy mit einem zweiten Block :8443 { reverse_proxy grampsweb:80 } in deploy/Caddyfile für TLS passend zum app-Dienst).

Anmerkungen

  • Daten bleiben in benannten Docker-Volumes erhalten (app-db, app-media, app-indexdir, app-users, app-secret, app-cache, app-tmp, postgres-data, caddy-data, caddy-config). docker compose down (ohne -v) behält sie; docker compose down -v löscht alles (einschließlich der erzeugten CA — das ist beim nächsten Start eine neue Browser-Vertrauenswarnung, nicht nur Datenverlust).
  • redis (Celery-Broker/Ergebnis-Backend) ist für Hintergrundaufgaben erforderlich (Suchindexierung, große Import-/Export-Jobs) — nicht optional. Ebenso der worker-Dienst: Import, Medien-Archiv-Upload und Suchindex-Neuaufbau werden alle über Celery verteilt, sobald GRAMPSWEB_CELERY_CONFIG__* gesetzt ist, und bleiben für immer als unerfüllte Aufgabe liegen ohne etwas, das die Warteschlange abarbeitet. app-cache (/app/cache) muss aus demselben Grund ein zwischen app und worker geteiltes Volume sein: der Request-Handler des app-Containers schreibt die hochgeladene Datei dorthin, dann liest sie der Celery-Task des worker-Containers zurück.
  • Der worker-Dienst läuft mit --pool=solo (kein Forking), aus Vorsicht davor, dass Celerys Standard-Prefork-Pool einen Prozess forkt, der bereits echten PyGObject-/GTK-Zustand berührt hat (Gramps' Import von gi.repository.GLib). Eine einzelne Worker-Instanz braucht die Parallelität nicht, die --pool=solo aufgibt, aber das ist einen erneuten Blick wert, sollte der Worker-Durchsatz je zum Flaschenhals werden.
  • Der browserseitige lokale Cache von app/ (sql.js, OPFS) ist nach einem festen Dateinamen pro Ansicht geschlüsselt, nicht nach Backend-URL oder Stammbaum-ID — siehe Architektur für die Falle, die das verursacht, und wie man sie löst.
  • .github/workflows/build-docker.yml (manuell ausgelöst — gh workflow run build-docker.yml, oder über den Actions-Tab) baut deploy/Dockerfile und veröffentlicht ghcr.io/<owner>/gramps-connect:latest. docker compose -f deploy/docker-compose.yml --env-file deploy/.env pull holt dieses stattdessen statt lokal zu bauen; up -d --build bleibt für lokales Iterieren am Dockerfile selbst bestehen.
⚠️ **GitHub.com Fallback** ⚠️