Deploying.de - dsblank/gramps-connect GitHub Wiki
🌐 English · Español · Français · Nederlands · 简体中文
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).
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 --buildDann https://localhost aufrufen (Caddy stellt sich automatisch selbst
ein lokales, selbstsigniertes Zertifikat aus — die Browser-Warnung
akzeptieren).
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 -dEine Domain auf den Host zeigen lassen und deploy/Caddyfile bearbeiten
(siehe TLS unten), um statt des selbstsignierten ein echtes
Zertifikat zu bekommen.
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.
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 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/fstabOptional, 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.
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 -dDamit wird das vorgebaute, öffentliche Image
ghcr.io/dsblank/gramps-connect:latest gezogen; auf dem Droplet selbst
wird nichts gebaut.
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.
Den Abschnitten Ersteinrichtung
und Daten importieren
unten folgen, dabei https://ihre.domain statt https://localhost
verwenden.
- 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_STARTTLSmuss kleingeschriebentruesein.Truewird 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.
Siehe Einen gehosteten Server aktualisieren unten.
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.
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.
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.
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 erzeugenNur 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 behebendeploy/.env wird von git ignoriert und ist daher nie betroffen.
diff .env.example .envNeu hinzugekommene Einstellungen aus .env.example in .env
übernehmen, dann erneut docker compose up -d ausführen, falls man
etwas geändert hat.
docker compose ps # app und worker frisch erzeugt, "Up"
docker compose logs --tail 50 app # gunicorn-Zeilen "Booting worker", keine TracebacksDann 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.
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 -dHat die neuere Version die Datenbank migriert, zusätzlich die Sicherung aus Schritt 1 wiederherstellen.
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.
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 shDiese 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.
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_KEYleer 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 indeploy/.enverforderlich — keinadmin/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.
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.toolBekannte 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).
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.
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-stoppedauf 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).
- 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 -vlö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 derworker-Dienst: Import, Medien-Archiv-Upload und Suchindex-Neuaufbau werden alle über Celery verteilt, sobaldGRAMPSWEB_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 zwischenappundworkergeteiltes 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 vongi.repository.GLib). Eine einzelne Worker-Instanz braucht die Parallelität nicht, die--pool=soloaufgibt, 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) bautdeploy/Dockerfileund veröffentlichtghcr.io/<owner>/gramps-connect:latest.docker compose -f deploy/docker-compose.yml --env-file deploy/.env pullholt dieses stattdessen statt lokal zu bauen;up -d --buildbleibt für lokales Iterieren am Dockerfile selbst bestehen.