Appearance
Deployment (Staging und Produktion)
PollYou läuft als Satz von Docker-Images auf einem CapRover-Server bei einem EU-Hoster (Empfehlung: Hetzner Cloud, Standort Falkenstein, Nürnberg oder Helsinki). Die CI baut die Images, schiebt sie in eine Registry und stößt CapRover an. Diese Seite beschreibt die einmalige Einrichtung, den laufenden Betrieb, Backups und Rollback.
Lokal vorab prüfen
make prod-check baut alle Produktions-Images, startet sie mit Produktionseinstellungen neben dem Dev-Stack, führt den Smoke-Test und einen Backup-/Restore-Test aus und räumt danach alles wieder ab (siehe Lokale Produktionsprüfung).
Coolify
Statt CapRover lässt sich die ganze Plattform auch als eine Coolify-Anwendung aus docker-compose.coolify.yml betreiben: Deployment mit Coolify.
Überblick
| Subdomain (Beispiel Staging) | CapRover-App | Image | Container-Port |
|---|---|---|---|
app.staging.pollyou.example | pollyou-staging-dashboard | pollyou-dashboard (Caddy, SPA + Proxy) | 8080 |
api.staging.pollyou.example | pollyou-staging-api | pollyou-backend (FrankenPHP) | 8080 |
| – | pollyou-staging-worker | pollyou-backend mit APP_ROLE=worker | – (kein Web) |
| – | pollyou-staging-scheduler | pollyou-backend mit APP_ROLE=scheduler | – (kein Web) |
embed.staging.pollyou.example | pollyou-staging-embed | pollyou-embed (Caddy, CDN-Assets) | 8080 |
rt.staging.pollyou.example | pollyou-staging-rt | dunglas/mercure:v0.21 | 80 |
staging.pollyou.example | pollyou-staging-site | pollyou-site (Caddy, Landing Page, optional) | 8080 |
docs.staging.pollyou.example | pollyou-staging-docs | pollyou-docs (Caddy, Doku und Rechtstexte) | 8080 |
| – | pollyou-staging-db | One-Click-App PostgreSQL 17 | – |
| – | pollyou-staging-redis | redis:7-alpine + deploy/redis/redis-queue.conf (Messenger-Queue, persistent) | – |
| – | pollyou-staging-cache | redis:7-alpine + deploy/redis/redis-cache.conf (Cache, Rate-Limiter, flüchtig) | – |
| – | pollyou-staging-backup | pollyou-backup (pg_dump + rclone, Cron) | – (kein Web) |
| – | pollyou-staging-gotenberg | gotenberg/gotenberg:8.37.0 (HTML → PDF) | 3000 (intern) |
Für Produktion gilt dasselbe mit pollyou-production-* und den Domains app., api., embed., rt. der Hauptdomain – idealerweise auf einem eigenen Server.
text
Browser ──TLS──▶ CapRover-nginx ──▶ app. Dashboard (Caddy) ──/api, /health──▶ API
├─▶ api. API (FrankenPHP) ──▶ PostgreSQL, Redis
├─▶ embed. Embed/CDN (Caddy)
└─▶ rt. Mercure
Worker 1–n (Messenger) ──▶ PostgreSQL, Redis, SMTP, Gotenberg
Scheduler (genau 1) ──▶ PostgreSQL, Redis (Aufgaben → Worker)Warum das Dashboard die API über sich selbst aufruft
Das Dashboard spricht die API same-origin unter https://app.…/api an; Caddy im Dashboard-Container leitet /api/* und /health intern an srv-captain--pollyou-staging-api:8080 weiter. Dadurch:
- liegt das Refresh-Cookie (
pollyou_refresh,HttpOnly,Secure,SameSite=Strict, Pfad/api/auth) aufapp.…und wird ohne CORS und ohnecredentials: 'include'mitgeschickt, - funktionieren Captcha (
/api/auth/captcha) und Health-Check ohne Konfiguration im Frontend (keineVITE_API_URLnötig), - bleibt
api.…für die öffentliche Collector-API, API-Keys und Integrationen.
Sollte das Dashboard später doch direkt mit api.… sprechen: app. und api. derselben registrierbaren Domain sind same-site, ein SameSite=Strict-Cookie auf api.… würde also bei credentials: 'include' weiterhin gesendet; nötig wären dann nur CORS_ALLOW_ORIGIN mit der Dashboard-Origin (Credentials sind in nelmio_cors bereits erlaubt) und eine konfigurierbare Basis-URL im Client. Ein Domain-Attribut am Cookie ist in keinem Fall nötig.
1. Server und CapRover
Hetzner-Cloud-Server anlegen: Ubuntu 24.04, Staging mind. CX32 (4 vCPU, 8 GB RAM), Produktion CPX41 oder größer. Für die Datenbank ein Hetzner-Volume einhängen (Docker-Datenverzeichnis oder Postgres-Volume darauf).
Firewall: eingehend 80, 443 und (nur während der Einrichtung) 3000; für Docker Swarm zwischen Cluster-Knoten 996, 2377, 7946, 4789.
CapRover installieren und einrichten (siehe CapRover-Doku):
bashdocker run -p 80:80 -p 443:443 -p 3000:3000 -e ACCEPTED_TERMS=true \ -v /var/run/docker.sock:/var/run/docker.sock -v /captain:/captain caprover/caprover npx caprover serversetup # Root-Domain z. B. staging.pollyou.example, Passwort, Let's Encrypt-E-MailDanach Port 3000 in der Firewall schließen; das Dashboard ist unter
https://captain.staging.pollyou.exampleerreichbar.
2. DNS
Beim DNS-Anbieter A- (und ggf. AAAA-)Records auf die Server-IP anlegen:
- Staging: ein Wildcard-Record
*.staging.pollyou.example. Er decktcaptain.,app.,api.,embed.,rt.unddocs.staging.pollyou.exampleab. - Produktion (ohne Wildcard): je ein Record für
app,api,embed,rtunddocsder Hauptdomain sowiecaptain.<Root-Domain>(CapRover-Root-Domain z. B.apps.pollyou.example).
Die TLS-Zertifikate stellt CapRover pro verbundener Domain über Let's Encrypt aus (HTTP-01, Port 80 muss offen sein) und erneuert sie automatisch.
3. Datenbank, Redis, Mercure
PostgreSQL – entweder CapRover-One-Click-App „PostgreSQL“ (Version 17, App-Name pollyou-staging-db, starkes Passwort, Datenbank pollyou) oder ein gemanagter Dienst mit EU-Standort (z. B. Scaleway, OVHcloud, Aiven in Frankfurt). Bei Managed Postgres übernimmt der Anbieter Point-in-Time-Recovery; der Backup-Sidecar unten ist trotzdem sinnvoll (unabhängige Kopie, Restore-Test).
Die PostgreSQL-Einstellungen für den Produktionsserver stehen unten unter PostgreSQL-Einstellungen.
Redis – zwei getrennte Instanzen, weil sie gegensätzliche Anforderungen haben (Begründung und Messungen: Last- und Sicherheitstests):
| App | Inhalt | Konfiguration | Variable |
|---|---|---|---|
pollyou-staging-redis | Messenger-Queue (Streams messages, messages_priority, verzögerte Nachrichten) | deploy/redis/redis-queue.conf: AOF mit appendfsync everysec, keine RDB-Snapshots, AOF-Rewrite erst ab 1 GB, maxmemory 3gb mit noeviction | MESSENGER_TRANSPORT_DSN |
pollyou-staging-cache | Rate-Limiter, verbrauchte Captcha-Lösungen, Auswertungs-Cache, Live-Zähler und deren Sperren | deploy/redis/redis-cache.conf: kein AOF, RDB alle 15 min, maxmemory 1gb mit allkeys-lru | REDIS_URL |
Beide Apps ohne Web-Zugang mit Persistent Directory /data anlegen und je ein Passwort als Umgebungsvariable REDIS_PASSWORD setzen; das Image ist redis:7-alpine mit der jeweiligen Konfiguration (captain-definitions in deploy/caprover/redis-queue und deploy/caprover/redis-cache):
bash
ci/deploy/caprover-tarball.sh redis-queue redis-queue.tar
npx caprover deploy -a pollyou-staging-redis -t redis-queue.tar
ci/deploy/caprover-tarball.sh redis-cache redis-cache.tar
npx caprover deploy -a pollyou-staging-cache -t redis-cache.tarDie Queue muss jede Nachricht überleben (Auswertung, Mails, Webhooks), der Cache dagegen darf alles verlieren: Ein fehlender Eintrag bedeutet nur einen Cache-Miss, ein neu beginnendes Rate-Limit-Fenster oder einen Live-Zähler „heute abgeschlossen“, der wieder bei 0 beginnt. In einer gemeinsamen Instanz mit AOF schrieb jeder Rate-Limiter-Zugriff ins AOF, die Standard-Snapshots (save … 60 10000) forkten unter Last jede Minute den ganzen Datenbestand samt Queue-Rückstand, und AOF-Rewrites blockierten den Hauptthread (aof-write bis 130 ms) – alle API-Requests warten in dieser Zeit auf den Rate-Limiter.
Mercure nutzt Redis nicht und hält Updates nur im Speicher (transport local, siehe unten). Bestehende Installation mit einer Redis-Instanz umstellen: Cache-App anlegen, REDIS_URL bei API, Worker und Scheduler auf sie zeigen lassen (Rate-Limits und Cache beginnen leer), dann die bisherige Instanz mit redis-queue.conf neu deployen (Daten unter /data bleiben, das AOF wird weiterverwendet). Die alten Cache-Schlüssel in der Queue-Instanz laufen von selbst ab.
Mercure (rt.) – neue App pollyou-staging-rt, Deployment über die mitgelieferte captain-definition (vorgefertigtes Image):
bash
ci/deploy/caprover-tarball.sh mercure mercure.tar
npx caprover deploy -a pollyou-staging-rt -t mercure.tarUmgebung: SERVER_NAME=:80, MERCURE_PUBLISHER_JWT_KEY und MERCURE_SUBSCRIBER_JWT_KEY = Wert von MERCURE_JWT_SECRET, MERCURE_EXTRA_DIRECTIVES mit den drei Zeilen cors_origins *, anonymous und transport local (eingebettete Umfragen auf beliebigen Websites abonnieren die öffentlichen Ergebnis-Topics ohne Token; private Topics des Dashboards brauchen weiterhin einen Subscriber-JWT, der per Query-Parameter authorization statt per Cookie übergeben wird). transport local hält die Updates nur im Speicher: Der Standard-Transport (Bolt) schreibt jedes Update mit fsync in eine History-Datenbank unter /data, was auf einer ausgelasteten Platte 20–350 ms pro Publish kostet und den Worker bremst. Eine History braucht PollYou nicht – jedes Update enthält den vollständigen Stand bzw. ist nur ein Signal zum Neuladen, und Embed und Dashboard laden nach einem Verbindungsabbruch neu (kein Nachholen per Last-Event-ID). Bestehende Installation: Zeile ergänzen und neu deployen; eine alte mercure.db unter /data/caddy/ kann gelöscht werden. Persistentes Verzeichnis /data (Caddy-Daten), Container-Port 80. Mercure nutzt Server-Sent Events und sendet X-Accel-Buffering: no, CapRovers nginx puffert die Streams daher nicht. Das Backend publiziert Live-Ergebnisse nach jeder abgeschlossenen Antwort (Worker, höchstens 1 Update/s je Umfrage).
PostgreSQL-Einstellungen
deploy/postgres/20-pollyou-tuning.sql enthält die Einstellungen für einen Server der Klasse Hetzner CPX41 (8 vCPU, 16 GB RAM, NVMe), auf dem PostgreSQL sich den Knoten mit API, Workern, Redis, Mercure und Gotenberg teilt. Einmalig als Superuser ausführen und PostgreSQL neu starten (shared_buffers und wal_buffers greifen erst nach einem Neustart); die Werte landen per ALTER SYSTEM in postgresql.auto.conf im Datenverzeichnis und überleben Updates des Containers:
bash
docker exec -i $(docker ps -qf name=srv-captain--pollyou-staging-db | head -n1) \
psql -v ON_ERROR_STOP=1 -U pollyou -d pollyou < deploy/postgres/20-pollyou-tuning.sql
# danach in CapRover: App pollyou-staging-db → Save & Restart| Einstellung | Wert | Warum |
|---|---|---|
shared_buffers / effective_cache_size | 4 GB / 10 GB | ~25 % bzw. ~60 % des RAM (der Rest gehört API, Workern, Redis) |
work_mem / maintenance_work_mem | 16 MB / 512 MB | Auswertungs-Abfragen, Index-Aufbau der Partitionen |
wal_buffers | 64 MB | viele kleine Commits pro Sekunde, weniger WALWrite-Wartezeit |
wal_compression | lz4 | kleinere Full-Page-Images nach jedem Checkpoint |
max_wal_size / min_wal_size | 8 GB / 2 GB | Checkpoints nach Zeit statt nach WAL-Menge (500 Stimmen/s erzeugen ~200 MB WAL pro Minute; mit dem Standard 1 GB alle paar Minuten ein Checkpoint) |
checkpoint_timeout / checkpoint_completion_target | 15 min / 0.9 | seltene, über die Zeit verteilte Checkpoints statt Schreibspitzen |
commit_delay / commit_siblings | 20 µs / 5 | Gruppen-Commit der übrigen synchronen Commits (Worker) bei vielen gleichzeitigen Transaktionen |
random_page_cost / effective_io_concurrency | 1.1 / 200 | NVMe |
log_checkpoints, log_lock_waits, log_min_duration_statement | an, an, 1 s | Checkpoints, Sperren > 1 s und langsame Abfragen im Log |
max_connections bleibt bei 100: Jeder FrankenPHP-Worker der API hält eine Verbindung (Standard 2 × CPU-Kerne = 16 je Instanz), jeder Messenger-Worker und der Scheduler eine. Bei mehr API-Instanzen oder FRANKENPHP_WORKER_COUNT > 40 erhöhen (oder PgBouncer davorschalten).
Asynchrone Commits des Collectors: Die Schreibzugriffe des öffentlichen Collectors (Session-Start, Zwischenspeichern, Abschluss, Events) committen mit synchronous_commit = off – nur diese Transaktionen, per SET LOCAL (COLLECT_ASYNC_COMMIT=1, Standard). Ein Absturz von PostgreSQL oder des Servers kann die Stimmen der letzten ≤ 600 ms verlieren, obwohl der Teilnehmer bereits eine Bestätigung bekam; die Datenbank bleibt konsistent, verlorene Antworten fehlen vollständig, die Rollups bleiben exakt. Alle anderen Schreibzugriffe (Konten, Abrechnung, Umfragen, Rollups) bleiben synchron. Ohne diese Abwägung warteten unter Last hunderte Commits pro Sekunde auf den WAL-Flush und hielten dabei Zeilensperren – die Hauptursache der Stauphasen. COLLECT_ASYNC_COMMIT=0 schaltet zurück. Details: backend/src/Collect/README.md, „Durability“.
Andere Hardware: Speicherwerte skalieren (shared_buffers ~25 %, effective_cache_size ~60 % des RAM, den PostgreSQL nutzen darf), bei gemanagtem PostgreSQL die Werte im Parameter-Panel des Anbieters setzen. Vor dem Launch den Lasttest auf der Zielhardware wiederholen (make load-test auf einem Server gleicher Größe, siehe Last- und Sicherheitstests) – die Zahlen dieses Repositories stammen von einer geteilten Entwickler-Workstation.
Datenbankrollen (Row-Level Security)
PostgreSQL trennt die Organisationen zusätzlich zur Anwendung per Row-Level Security (Details: backend/src/Tenancy/README.md, „Row-level security“). Dafür meldet sich jeder Teil mit einer eigenen Rolle an:
| Rolle | Wer | Variable |
|---|---|---|
Eigentümer (bisheriger Benutzer, z. B. pollyou) | Migrationen, Backups | DATABASE_MIGRATIONS_URL (API), Backup-DATABASE_URL |
pollyou_app | API – sieht nur die Organisation der Anfrage | DATABASE_URL der API |
pollyou_worker | Worker, Scheduler, Konsole – darf organisationsübergreifend arbeiten (Systembereich), Jobs einer Organisation laufen auf diese beschränkt | DATABASE_URL des Workers |
Die Policies gelten nicht für den Eigentümer der Tabellen (kein FORCE ROW LEVEL SECURITY) und nicht für Superuser – API und Worker dürfen deshalb nie mit dem Eigentümer oder einem Superuser verbunden sein. Rollen gelten clusterweit. Einmalig mit dem Eigentümer bzw. Superuser anlegen (psql auf der Datenbank pollyou, Passwörter mit openssl rand -hex 24 erzeugen):
sql
CREATE ROLE pollyou_app LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
CREATE ROLE pollyou_worker LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;(Gleiches Skript für ein leeres Datenverzeichnis: deploy/postgres/10-pollyou-roles.sh mit POLLYOU_APP_PASSWORD/POLLYOU_WORKER_PASSWORD.) Rechte und Policies vergibt die Migration; fehlen die Rollen, legt sie sie ohne LOGIN an, dann genügt ALTER ROLE pollyou_app LOGIN PASSWORD '…' (ebenso für pollyou_worker).
Umstellung einer bestehenden Installation: Neue Version deployen (läuft weiter mit dem bisherigen Benutzer, das Log warnt „Row-level security: … bypasses row-level security“), Rollen anlegen, dann in CapRover bei der API DATABASE_MIGRATIONS_URL auf den bisherigen Wert und DATABASE_URL auf pollyou_app setzen, beim Worker DATABASE_URL auf pollyou_worker, bei beiden DATABASE_ROLE_STRICT=1. Das Backup behält den Eigentümer. Prüfen: Log beim Start („Row-level security: enforced for "pollyou_app" (app role)“) bzw. php bin/console app:database:check-role --expect=app --strict in der API und --expect=worker im Worker.
Indizes: Unter Row-Level Security nutzt PostgreSQL GIN- und Ausdrucks-Indizes (Metadaten-Filter, Freitextsuche, Kontaktsuche) nur, wenn die beteiligten Funktionen als LEAKPROOF markiert sind. Das erledigt die Migration, wenn sie als Superuser läuft; sonst erscheint eine Warnung, und ein Superuser führt einmalig aus:
sql
ALTER FUNCTION jsonb_contains(jsonb, jsonb) LEAKPROOF;
ALTER FUNCTION ts_match_vq(tsvector, tsquery) LEAKPROOF;
ALTER FUNCTION to_tsvector(regconfig, text) LEAKPROOF;
ALTER FUNCTION tsvector_concat(tsvector, tsvector) LEAKPROOF;
ALTER FUNCTION lower(text) LEAKPROOF;Gotenberg (PDF-Berichte, nur intern) – neue App pollyou-staging-gotenberg, ebenfalls über die mitgelieferte captain-definition (offizielles Image, die Startparameter stehen in der Definition):
bash
ci/deploy/caprover-tarball.sh gotenberg gotenberg.tar
npx caprover deploy -a pollyou-staging-gotenberg -t gotenberg.tarContainer-Port 3000, Do not expose as web-app (nur der Worker ruft http://srv-captain--pollyou-staging-gotenberg:3000 auf), kein persistentes Verzeichnis. Die Parameter schalten JavaScript ab und erlauben dem Chromium nur die hochgeladenen Dateien (--chromium-allow-list=^file:///tmp/.*, private Adressen gesperrt): Der Bericht enthält alles inline (CSS, SVG-Diagramm, Logo als Data-URI), Gotenberg lädt also nie etwas nach. Speicherbedarf ca. 300–500 MB (Chromium); die App kann ohne Ausfall der übrigen Dienste neu gestartet werden – Berichte, die in der Zeit angefordert werden, schlagen fehl und können neu angefordert werden. Ohne Gotenberg funktionieren alle anderen Exporte (CSV, XLSX, SPSS) weiter.
4. CapRover-Apps und Umgebungsvariablen
Apps anlegen (Persistent Data nur bei DB, Redis, Mercure), bei allen Web-Apps unter HTTP Settings den Container-Port eintragen, die Subdomain verbinden, Enable HTTPS (Let's Encrypt) und Force HTTPS aktivieren. Worker, Scheduler und Backup: Do not expose as web-app.
Secrets erzeugen, z. B. openssl rand -hex 32 (APP_SECRET, ALTCHA_HMAC_KEY, MERCURE_JWT_SECRET), openssl rand -base64 48 (JWT_SECRET, TWO_FACTOR_ENCRYPTION_KEY). Secrets je Umgebung unterschiedlich wählen und zusätzlich im Passwort-Manager ablegen.
WARNING
TWO_FACTOR_ENCRYPTION_KEY verschlüsselt die TOTP-Secrets. Ein späterer Wechsel macht alle Zwei-Faktor-Einrichtungen unbrauchbar. JWT_SECRET zu ändern meldet alle Nutzer ab.
API (pollyou-staging-api, Port 8080, Instanzen: 1–n)
| Variable | Beispiel / Hinweis |
|---|---|
APP_ROLE | api (Standard) |
RUN_MIGRATIONS | 1 – Migrationen beim Start (nur hier, nicht im Worker) |
APP_SECRET | Secret |
DATABASE_URL | postgresql://pollyou_app:…@srv-captain--pollyou-staging-db:5432/pollyou?serverVersion=17&charset=utf8 (Rolle pollyou_app, siehe Datenbankrollen) |
DATABASE_MIGRATIONS_URL | postgresql://pollyou:…@srv-captain--pollyou-staging-db:5432/pollyou?serverVersion=17&charset=utf8 (Eigentümer des Schemas, nur für die Migrationen beim Start) |
DATABASE_ROLE_STRICT | 1 – Start verweigern, wenn DATABASE_URL die Row-Level Security umgeht (empfohlen, sobald die Rollen eingerichtet sind) |
REDIS_URL | redis://:…@srv-captain--pollyou-staging-cache:6379 (flüchtige Instanz: Rate-Limiter, Cache, Live-Zähler) |
MESSENGER_TRANSPORT_DSN | redis://:…@srv-captain--pollyou-staging-redis:6379/messages (persistente Queue-Instanz) |
JWT_SECRET | Secret (mind. 256 Bit) |
TWO_FACTOR_ENCRYPTION_KEY | Secret (mind. 32 Zeichen, nie ändern) |
ALTCHA_HMAC_KEY | Secret |
MAILER_DSN | SMTP eines EU-Anbieters, z. B. smtp://user:pass@smtp.example.eu:587 (nur SMTP; sendmail:// und native:// sind im Image gesperrt, siehe PHP-zu-Shell-Härtung unten) |
MAILER_FROM | PollYou <noreply@pollyou.example> (Domain mit SPF/DKIM/DMARC) |
DASHBOARD_URL | https://app.staging.pollyou.example (Links in E-Mails) |
DEFAULT_URI | https://api.staging.pollyou.example (URLs in CLI/Worker) |
CORS_ALLOW_ORIGIN | ^https://app\.staging\.pollyou\.example$ (Regex; Collector-CORS regelt die Embed-Konfiguration) |
TRUSTED_PROXIES | Standard PRIVATE_SUBNETS (CapRover-nginx und Dashboard-Proxy haben private Adressen) |
REFRESH_COOKIE_SECURE | Standard true |
SENTRY_DSN | DSN des Projekts pollyou-api (EU-Region de.sentry.io), leer = aus |
SENTRY_ENVIRONMENT | staging bzw. production |
MERCURE_URL | http://srv-captain--pollyou-staging-rt/.well-known/mercure (Live-Ergebnisse, intern) |
MERCURE_PUBLIC_URL | https://rt.staging.pollyou.example/.well-known/mercure (für Browser, geht an Embeds/Dashboard) |
MERCURE_JWT_SECRET | Secret, identisch mit dem Mercure-Hub |
EMBED_SCRIPT_URL | https://embed.staging.pollyou.example/v1/embed.js (lädt die Direktlink-Seite /s/{publicId}) |
GOTENBERG_URL | http://srv-captain--pollyou-staging-gotenberg:3000 (PDF-Berichte; nur der Worker braucht es) |
ANTHROPIC_API_KEY | Optional, Secret: Claude-API-Schlüssel für die KI-Auswertung von Freitext (leer = nicht verfügbar; API und Worker) |
DEEPL_API_KEY | Optional, Secret: DeepL-API-Schlüssel für „Vorübersetzen“ im Übersetzungseditor (leer = ausgeblendet; nur API) |
FRANKENPHP_WORKER_COUNT | Anzahl PHP-Worker, z. B. 8; leer = 2 × CPU-Kerne (RAM beachten, ca. 50 MB je Worker) |
FRANKENPHP_WORKER_MODE | Standard 1; 0 schaltet auf den klassischen Modus zurück |
DB_WAIT_TIMEOUT | Sekunden, die der Start auf die Datenbank wartet (Standard 60) |
CUSTOM_DOMAIN_TARGET | Optional: CNAME-Ziel eigener Kunden-Domains, z. B. custom.pollyou.example (leer = aus, siehe TLS-Edge) |
CUSTOM_DOMAIN_EDGE_SECRET | Optional, Secret: gemeinsames Secret mit dem Edge (mind. 32 Zeichen; API und Worker) |
METRICS_TOKEN | Optional, Secret: Bearer-Token für /internal/metrics und die Caddy/FrankenPHP-Metriken auf dem internen Port 9180 (nur Buchstaben, Ziffern, -, _; leer = aus, siehe Monitoring) |
Persistentes Verzeichnis für Exporte: CSV-/XLSX-/SPSS-Exporte und PDF-Berichte liegen bis zu 7 Tage in /var/lib/pollyou/exports (EXPORT_STORAGE_DIR, im Image vorbelegt). API, Worker und Scheduler müssen dasselbe Verzeichnis sehen, denn der Worker schreibt große Exporte, die API liefert sie aus und der Scheduler löscht abgelaufene. In CapRover bei allen drei Apps unter „App Configs → Persistent Directories“ den Pfad /var/lib/pollyou/exports mit demselben Host-Pfad (z. B. /captain/data/pollyou-staging-exports, Eigentümer UID 10001 = User pollyou im Image) einhängen; alle drei Apps müssen dafür auf demselben Knoten laufen. Ohne gemeinsames Verzeichnis schlagen asynchrone Exporte beim Download mit 404 fehl.
Ergebnis-Links (/r/{token}) und Ergebnis-Badges (/s/{publicId}/badge.svg|png) liefert die API wie den Direktlink aus. Für die PNG-Badges enthält das Image die PHP-Erweiterung gd (mit FreeType) und die Schriften aus fonts-dejavu-core; BADGE_FONT_DIR ist in backend/.env.prod vorbelegt (/usr/share/fonts/truetype/dejavu).
SENTRY_RELEASE setzt das Image selbst (pollyou@<commit>). SESSION_LIFETIME_DAYS (30), ALTCHA_MAX_NUMBER (100000), COLLECT_MIN_COMPLETION_MS (2000), COLLECT_ABANDON_AFTER_HOURS (24; Stunden, die eine begonnene Antwort fortgesetzt werden kann, danach gilt sie als abgebrochen), COLLECT_ASYNC_COMMIT (1; asynchrone Commits des Collectors, siehe PostgreSQL-Einstellungen), COLLECT_TRUSTED_ORIGINS (leer; zusätzliche Origins, die den Collector immer aufrufen dürfen, kommagetrennt) GEOIP_DATABASE_PATH (leer = kein Land; Pfad zu einer MaxMind-.mmdb in einem Persistent Directory) sowie für Webhooks WEBHOOK_ALLOW_HTTP (false; nur HTTPS-Ziele), WEBHOOK_ALLOWED_PRIVATE_NETWORKS (leer; private Netze bleiben gesperrt, SSRF-Schutz) und WEBHOOK_RETRY_DELAYS (Sekunden zwischen den Zustellversuchen) haben Standardwerte.
Optional für den Guthabenkauf mit Stripe: STRIPE_SECRET_KEY (Secret, API, Worker und Scheduler; leer = Kauf aus, das Dashboard zeigt „Kauf noch nicht verfügbar“, Guthaben lässt sich weiter per app:credits:grant vergeben), STRIPE_WEBHOOK_SECRET (Secret, nur die API; Signing Secret des Endpunkts https://api.<domain>/api/billing/stripe/webhook), BILLING_AUTOMATIC_TAX (1). Alle sind in backend/.env.prod leer bzw. vorbelegt, das Image startet ohne sie. Nach dem ersten Setzen einmal bin/console app:billing:sync-products im Worker-Container ausführen; Prüfung im Testmodus: Runbook.
Optional für den OAuth-Login: OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET, OAUTH_GITHUB_CLIENT_ID, OAUTH_GITHUB_CLIENT_SECRET (in backend/.env.prod leer vorbelegt; leer = Anbieter ausgeblendet).
Der Container prüft beim Start, dass alle Pflichtvariablen gesetzt sind und keine Entwicklungs-Platzhalter (dev-only, change-me) enthalten, und bricht sonst mit einer Meldung im Log ab. Das Image enthält keine .env mit Entwicklungswerten, nur die nicht geheimen Standardwerte aus backend/.env.prod.
PHP-zu-Shell-Härtung: Im Produktions-Image sind alle PHP-Funktionen abgeschaltet, die einen Prozess oder eine Shell starten könnten (disable_functions: exec, passthru, shell_exec, system, proc_open, popen, pcntl_exec, dl, mail – backend/docker/php/conf.d/30-prod-hardening.ini). Selbst eine gelungene Code-Injektion in PHP kann so keinen Shell-Befehl ausführen. Die Anwendung braucht keine davon: Mails gehen über SMTP (deshalb kein sendmail:///native://-MAILER_DSN), PDF-Berichte rendert Gotenberg per HTTP, und pcntl bleibt für die Worker-Signale verfügbar (nur pcntl_exec ist gesperrt). PHPStan verbietet diese Funktionen zusätzlich im Code (disallowedFunctionCalls in backend/phpstan.dist.neon); das Dev-Image bleibt ungesperrt, weil Composer, php-cs-fixer & Co. dort proc_open brauchen.
Worker (pollyou-staging-worker, kein Web, Instanzen: 1–n)
Gleiche Variablen wie die API, aber APP_ROLE=worker, DATABASE_URL mit der Rolle pollyou_worker (postgresql://pollyou_worker:…@…) und ohne RUN_MIGRATIONS/DATABASE_MIGRATIONS_URL. Wartungsbefehle (bin/console app:…) im Worker-Container ausführen: Nur die Worker-Rolle sieht organisationsübergreifend alle Daten. Der Worker verarbeitet zwei Queues in fester Reihenfolge (messenger:consume async_priority async):
async_priority(Redis-Stream<stream>_priority, z. B.messages_priority): E-Mails (Bestätigungs-Mail, Passwort-Reset, Einladungen …), Webhook-Zustellungen und Benachrichtigungen (Berichte, Alarme). Ein Worker nimmt nur dann Arbeit ausasync, wenn diese Queue leer ist – eine Bestätigungs-Mail wartet also auch bei einem Rückstau von Zehntausenden Auswertungs-Nachrichten nur, bis ein Worker seine aktuelle Nachricht beendet hat.async: alles Übrige (Rollups und Live-Updates der Auswertung, Anomalie-Erkennung, große Exporte, PDF-Berichte über Gotenberg, KI-Auswertung).
Beide Streams liegen im Redis von MESSENGER_TRANSPORT_DSN; der zweite wird daraus abgeleitet, eine eigene Variable ist nicht nötig.
Skalieren: Die Anzahl der Instanzen lässt sich in CapRover beliebig erhöhen (App Configs → Instance Count), z. B. wenn der Rollup-Verzug unter Last wächst. Jeder Container liest unter einem eigenen Redis-Consumer-Namen (Standard: Hostname = Container-ID; überschreibbar mit MESSENGER_CONSUMER_NAME, dann aber je Instanz verschieden!). Mit einem gemeinsamen Namen würden Worker die gerade bearbeiteten Nachrichten der anderen übernehmen, doppelt verarbeiten und mit „Could not acknowledge redis message“ abbrechen. Beim normalen Stoppen (SIGTERM, z. B. Deployment) beendet ein Worker die laufende Nachricht, verarbeitet einen angefangenen Rollup-Batch und bestätigt alles (pcntl im Image).
Abgestürzte Worker: Gelesene, aber nicht bestätigte Nachrichten eines Containers, der ohne geordnetes Beenden weg ist (Absturz, OOM-Kill, SIGKILL nach dem Stop-Timeout) – darunter bis zu 100 Nachrichten eines gepufferten Rollup-Batches –, übernimmt ein anderer Worker nach 5 Minuten (redeliver_timeout: 300, geprüft alle 30 s, config/packages/messenger.yaml; vorher eine Stunde). Damit lang laufende Handler (Exporte, PDF-Berichte, KI-Auswertung: Minuten) nicht fälschlich übernommen werden, erneuert jeder Worker alle 30 s seine offenen Nachrichten (messenger:consume --keepalive, WORKER_KEEPALIVE, Standard 30; muss deutlich unter 300 bleiben). Ein Timeout muss also nur länger sein als der längste Abschnitt ohne Keepalive (ein einzelner blockierender Aufruf, z. B. Gotenberg ≤ 60 s), nicht als der längste Handler. Doppelt zugestellte Nachrichten wären ohnehin harmlos: Rollups zählen genau einmal, Exporte und KI-Auswertungen laufen nur im Status „queued“. Einschränkung: Der Transport prüft bei jeder Runde nur die älteste offene Nachricht der Gruppe – hält ein lebender Worker gerade eine ältere (z. B. einen langen Export), übernimmt niemand die Nachrichten des toten Workers, bis dieser Export fertig ist.
Er beendet sich nach WORKER_TIME_LIMIT (3600 s) bzw. WORKER_MEMORY_LIMIT (256M) und wird von Docker Swarm neu gestartet. Abgelöste Consumer-Namen bleiben in der Redis-Consumer-Group stehen (XINFO CONSUMERS messages symfony), sind aber leer und stören nicht; bei Bedarf mit XGROUP DELCONSUMER aufräumen.
Scheduler (pollyou-staging-scheduler, kein Web, genau 1 Instanz)
Gleiche Variablen wie der Worker (also auch DATABASE_URL mit pollyou_worker), aber APP_ROLE=scheduler (ebenfalls ohne RUN_MIGRATIONS, gleiches Image, gleiches App-Token-Verfahren). Er führt den Symfony Scheduler aus (messenger:consume scheduler_default, App\Schedule): Umfragen starten/schließen, fällige Webhook-Wiederholungen, geplante Berichte und Alarme, Partitionen, Aufräumjobs. Die Aufgaben selbst sind kurz oder legen nur Nachrichten für die Worker ab. Nie mehr als eine Instanz – jede weitere würde jede geplante Aufgabe noch einmal ausführen (doppelte Berichte, Alarme). Das Persistent Directory für Exporte (/var/lib/pollyou/exports) wie bei API und Worker einhängen: app:analytics:purge-exports und app:reports:purge laufen hier und löschen abgelaufene Dateien.
Umstellung bestehender Umgebungen
Bis zu dieser Version lief der Scheduler im Worker (APP_ROLE=worker). Vor dem ersten Deployment mit dieser Version die App …-scheduler anlegen (Variablen der Worker-App kopieren, APP_ROLE=scheduler, Instanzen 1, App-Token als CAPROVER_APP_TOKEN_SCHEDULER hinterlegen). Ohne sie bricht ci/deploy/caprover-deploy.sh vor dem ersten Deployment-Schritt ab – sonst liefe danach kein Scheduler mehr. Die Worker-App kann anschließend auf mehrere Instanzen gestellt werden.
Dashboard (pollyou-staging-dashboard, Port 8080)
| Variable | Beispiel / Hinweis |
|---|---|
API_UPSTREAM | srv-captain--pollyou-staging-api:8080 (interner Name der API-App) |
CSP_CONNECT_SRC | https://api.staging.pollyou.example https://rt.staging.pollyou.example https://*.ingest.de.sentry.io |
CSP_FRAME_SRC | https://embed.staging.pollyou.example (Vorschau eingebetteter Umfragen) |
Build-Zeit (in der CI als Repository-Variable, beim Server-Build als App-Variable, die CapRover als Build-Arg weitergibt): VITE_SENTRY_DSN, VITE_SENTRY_ENVIRONMENT, VITE_SENTRY_RELEASE (setzt die CI) sowie die öffentlichen Adressen in Embed-Codes, Ergebnis-Links und API-Doku: VITE_EMBED_SCRIPT_URL (https://embed.…/v1/embed.js), VITE_PUBLIC_BASE_URL und VITE_COLLECT_API_BASE (beide die API, https://api.…), VITE_DOCS_URL. Die CI nimmt dafür SITE_EMBED_SCRIPT_URL, SITE_API_URL und SITE_DOCS_URL. Fehlen sie, zeigen die Embed-Codes auf localhost.
Caddy liefert: SPA-Fallback auf index.html (Cache-Control: no-cache), gehashte Assets unter /assets/ ein Jahr immutable (fehlende Assets 404 statt index.html), vorkomprimiert als Brotli und gzip, sowie die Sicherheits-Header Content-Security-Policy (default-src 'self', script-src 'self'; Styles brauchen 'unsafe-inline' wegen PrimeVue), Strict-Transport-Security, X-Frame-Options: DENY, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy.
Embed (pollyou-staging-embed, Port 8080)
Keine Variablen. Liefert den CDN-Build von frontend/packages/embed-core (dist/cdn) aus: versionierte Pfade /v1.2.3/… ein Jahr immutable, der Major-Alias /v1/… 5 Minuten (stale-while-revalidate), alles andere 5 Minuten; Access-Control-Allow-Origin: *. Solange embed-core im Repository fehlt, baut das Image eine Platzhalterseite; sobald das Paket existiert, muss sein Build dist/cdn erzeugen, sonst schlägt der Image-Build fehl.
Landing Page (pollyou-staging-site, Port 8080, optional)
Statische Website aus frontend/apps/site (Startseite, Funktionen, Preise; Deutsch und /en/; Preis, Pakete und Funktionen kommen beim Build aus frontend/packages/plans/plans.json, siehe Preise und Guthaben), Image pollyou-site (frontend/docker/site.Dockerfile, deploy/caprover/site/captain-definition). Keine Laufzeit-Variablen: Alle Adressen werden beim Build eingebacken (kanonische URLs, Sitemap, Links, CSP). Die CI reicht dafür die Repository-Variablen SITE_URL, SITE_DASHBOARD_URL, SITE_DOCS_URL, SITE_EMBED_SCRIPT_URL, SITE_API_URL und SITE_DEMO_SURVEY_ID (öffentliche ID der Live-Demo-Umfrage; leer = statische Vorschau) als Build-Args weiter; leere Werte nehmen die Vorgaben aus frontend/apps/site/site.config.ts (https://<Domain>, app., embed., api.; die Doku immer https://docs.pollyou.org). Ausgerollt wird sie, sobald CAPROVER_APP_TOKEN_SITE gesetzt ist. Die Hauptdomain (und ggf. www. mit Weiterleitung in CapRover) wird mit dieser App verbunden.
Caddy liefert: eine aus dem Build erzeugte, strenge Content-Security-Policy (default-src 'none', Skripte nur von der eigenen Origin plus SHA-256-Hash des einen Inline-Skripts; Embed-CDN, API und Inline-Styles nur, wenn die Live-Demo konfiguriert ist), Strict-Transport-Security, X-Frame-Options: DENY, Permissions-Policy, Referrer-Policy, Cross-Origin-Opener-Policy; /_astro/ ein Jahr immutable, alles andere no-cache; 404-Seiten auf Deutsch bzw. unter /en/ auf Englisch. Lokal prüfen: make site-check (Image bauen, Header, Lighthouse, siehe frontend/apps/site/README.md).
Doku (pollyou-staging-docs, Port 8080)
Die Benutzerdokumentation aus docs/ (VitePress) mit den Rechtstexten unter /legal/ (Impressum, Datenschutzerklärung, AGB, AVV, Unterauftragsverarbeiter), Image pollyou-docs (frontend/docker/docs.Dockerfile, deploy/caprover/docs/captain-definition). Keine Build-Args und keine Laufzeit-Variablen. Dashboard und Landing Page verlinken Impressum und Datenschutz auf diese App (SITE_DOCS_URL bzw. VITE_DOCS_URL, Vorgabe https://docs.pollyou.org) – ohne sie laufen diese Links ins Leere. Ausgerollt wird sie, sobald CAPROVER_APP_TOKEN_DOCS gesetzt ist; die Subdomain docs. wird mit dieser App verbunden.
Caddy liefert: eine beim Build erzeugte Content-Security-Policy (docs/scripts/csp.mjs: default-src 'none', Skripte nur von der eigenen Origin plus SHA-256-Hashes der VitePress-Inline-Skripte, Inline-Styles erlaubt, weil VitePress style-Attribute rendert), dieselben Sicherheits-Header wie die Landing Page, /assets/ ein Jahr immutable, alles andere no-cache; Pfade ohne .html (cleanUrls), eigene 404-Seite.
OAuth-Login
„Mit Google anmelden“ und „Mit GitHub anmelden“ erscheinen nur, wenn Client-ID und Secret des Anbieters gesetzt sind (Variablen der API-App; der Worker braucht sie nicht). Der Callback läuft über das Dashboard, das /api an die API weiterreicht. Die Redirect-URI ist deshalb immer:
<DASHBOARD_URL>/api/auth/oauth/google/callback
<DASHBOARD_URL>/api/auth/oauth/github/callbackz. B. https://app.staging.pollyou.example/api/auth/oauth/google/callback. Für Staging und Produktion je eigene Apps anlegen.
Google (Google Cloud Console → APIs & Dienste):
- Projekt wählen oder anlegen, unter „OAuth-Zustimmungsbildschirm“ Typ „Extern“, App-Name „PollYou“, Support- und Entwickler-Adresse, Domain des Dashboards eintragen; Bereiche
openid,email,profilegenügen (keine Prüfung durch Google nötig). - „Anmeldedaten“ → „Anmeldedaten erstellen“ → „OAuth-Client-ID“, Typ „Webanwendung“.
- Autorisierte Weiterleitungs-URI:
<DASHBOARD_URL>/api/auth/oauth/google/callback(JavaScript-Quellen werden nicht gebraucht). - Client-ID und Clientschlüssel als
OAUTH_GOOGLE_CLIENT_IDundOAUTH_GOOGLE_CLIENT_SECRETsetzen. Den Zustimmungsbildschirm danach „veröffentlichen“, sonst können sich nur Testnutzer anmelden.
GitHub (Organisation → Settings → Developer settings → OAuth Apps → „New OAuth App“; keine GitHub App):
- Application name „PollYou“, Homepage URL =
DASHBOARD_URL. - Authorization callback URL:
<DASHBOARD_URL>/api/auth/oauth/github/callback. - Client-ID und ein neu erzeugtes Client Secret als
OAUTH_GITHUB_CLIENT_IDundOAUTH_GITHUB_CLIENT_SECRETsetzen. Die App fragt den Scopeuser:emailan, um die bestätigte primäre Adresse zu lesen.
Beide Anbieter laufen mit state und PKCE (S256). Ein Konto, dessen bestätigte Anbieter-Adresse schon in PollYou existiert, wird erst nach Bestätigung per E-Mail verknüpft – Mailversand muss also funktionieren. Details zum Ablauf: Lokale Entwicklung. Der Fake-Anbieter für Entwicklung und Tests (OAUTH_FAKE_PROVIDERS) ist in Produktion wirkungslos.
Eigene Domains der Kunden (TLS-Edge)
Kunden können ihre Umfragen unter eigenen Domains ausliefern (umfrage.kunde.de → Direktlink, iframe-Seite, Badges, Ergebnis-Links, oEmbed, Collector-API; Anleitung für Kunden: Eigene Domains). Dafür braucht es TLS-Zertifikate für beliebige, vorher unbekannte Hostnamen – das kann CapRovers nginx nicht (Zertifikate nur pro manuell verbundener Domain und App). Entscheidung: ein eigener, kleiner Edge-Server mit dem Image pollyou-edge (deploy/edge, Caddy mit On-Demand-TLS) vor der API:
text
Browser ──TLS (Zertifikat für umfrage.kunde.de, Let's Encrypt)──▶ Edge (Caddy, eigene IP, 80/443)
umfrage.kunde.de CNAME custom.pollyou.org ─────┘ │ ask: /internal/domains/allowed?domain=…
▼ (nur bestätigte Domains bekommen ein Zertifikat)
https://api.pollyou.example ──▶ CapRover-nginx ──▶ API (FrankenPHP)
Host: api.…, X-PollYou-Edge-Secret/-Host/-Client-IpWarum nicht anders: CapRovers nginx belegt 80/443 auf allen Adressen des Servers (Swarm-Ingress), ein zweiter TLS-Terminator auf demselben Server bräuchte einen Umbau von CapRover. Kunden-Domains einzeln als „Custom Domain“ an der API-App einzutragen skaliert nicht (Handarbeit pro Kunde, Neustart von nginx). Der Edge-Server ist zustandsarm (nur Zertifikate), lässt sich unabhängig neu starten und trennt Kunden-Traffic vom Dashboard.
Einrichtung (einmalig):
Server: kleine VM im selben Rechenzentrum (z. B. Hetzner CX22), Docker installiert. Firewall eingehend nur 80 (ACME-HTTP-Challenge, Weiterleitung auf https) und 443.
DNS:
custom.pollyou.example(A und ggf. AAAA) auf die IP des Edge-Servers. Dieser Name ist das CNAME-Ziel der Kunden; er selbst bekommt kein Zertifikat und muss nicht erreichbar sein.Secret:
openssl rand -hex 32– identisch in der API (CUSTOM_DOMAIN_EDGE_SECRET) und im Edge (POLLYOU_EDGE_SECRET), mindestens 32 Zeichen.API-App (CapRover, App Configs):
CUSTOM_DOMAIN_TARGET=custom.pollyou.example,CUSTOM_DOMAIN_EDGE_SECRET=<secret>; optionalCUSTOM_DOMAIN_DNS_SERVERS(z. B.1.1.1.1,9.9.9.9, sonst der Resolver des Containers) undCUSTOM_DOMAIN_RESERVED(weitere eigene Domains, die Kunden nicht eintragen dürfen; die Domain des Ziels, z. B.pollyou.example, und alle PollYou-Hosts sind automatisch gesperrt). OhneCUSTOM_DOMAIN_TARGETist die Funktion aus (422custom_domains_unavailable). Der Worker prüft offene Domains über den Scheduler (app:custom-domains:check, alle 10 Minuten) und braucht dieselben Variablen.Edge starten (Image aus der CI,
ci/deploy/build-images.shbautpollyou-edge, oder lokal ausdeploy/edge):bashdocker volume create pollyou-edge-data docker run -d --name pollyou-edge --restart unless-stopped -p 80:80 -p 443:443 \ -v pollyou-edge-data:/data \ -e POLLYOU_UPSTREAM=https://api.pollyou.example \ -e POLLYOU_EDGE_SECRET=<secret> \ -e ACME_EMAIL=ops@pollyou.example \ registry.pollyou.example/pollyou/pollyou-edge:production/dataenthält Zertifikate und ACME-Konto – unbedingt persistent halten (sonst werden beim Neustart alle Zertifikate neu bestellt und Let's-Encrypt-Limits greifen). Liegt der Edge im selben Docker-Swarm-Overlay wie die API, kannPOLLYOU_UPSTREAMauchhttp://srv-captain--pollyou-production-api:8080sein.Test: eine Test-Domain in einer Organisation eintragen, die DNS-Einträge anlegen, „Jetzt prüfen“, dann
curl -I https://<test-domain>/s/<publicId>→ 200 mit gültigem Let's-Encrypt-Zertifikat; der Status wechselt auf „Aktiv“.
Wie die Teile zusammenspielen:
- Zertifikate nur für bestätigte Domains: Caddy fragt vor jeder neuen Ausstellung
GET <POLLYOU_UPSTREAM>/internal/domains/allowed?token=<secret>&domain=<host>. Die API antwortet 200 nur für Domains mit Status bestätigt oder aktiv (TXT_pollyou-verification.<host>mit dem Token und CNAME bzw. dieselben A/AAAA-Adressen wie das Ziel), sonst 404 (403 bei falschem Token). Unbekannte Hostnamen bekommen keinen TLS-Handshake. Ausstellungen sind pro Hostname auf 20 pro Stunde begrenzt (429); Caddy fragt ohnehin nur, wenn es kein gültiges Zertifikat hat. - Edge-Header: Der Edge setzt
X-PollYou-Edge-Secret,X-PollYou-Edge-Host(Kunden-Hostname) undX-PollYou-Edge-Client-Ip. Nur mit gültigem Secret übernimmt die API Hostname, Client-IP (Rate-Limits, IP-Dedup) und https;X-Forwarded-*dieser Anfragen werden verworfen, die Kette Edge → CapRover-nginx → API spielt also keine Rolle. Ein falsches Secret ergibt 403. - Routing: Auf Kunden-Domains liefert die API nur öffentliche Seiten der Organisation der Domain (
/s/…,/p/…, Badges,/r/…,/oembed,/api/collect/…); Dashboard-API, Docs,/healthund/internalsind dort 404, ebenso Umfragen, Sessions und Ergebnis-Links anderer Organisationen.EMBED_SCRIPT_URL(CDN) und Mercure bleiben auf den PollYou-Domains. - Lebenszyklus: offene Domains prüft der Worker 7 Tage lang alle 10 Minuten, bestätigte/aktive täglich. Schlagen die Prüfungen einer laufenden Domain 72 Stunden lang fehl, wird sie abgeschaltet (Audit-Eintrag
custom_domain.failed), Caddy erneuert ihr Zertifikat dann nicht mehr. - Plan:
POST /api/custom-domainsverlangt die Plan-Funktioncustom_domains(PlanLimitChecker); sie ist im Standardplan enthalten, also für alle Kundinnen und Kunden ohne Aufpreis.
Secret im Ask-URL
Caddy kann dem Ask-Endpunkt keine Header mitgeben, das Secret steht deshalb im Query-String. CapRovers nginx protokolliert Anfragen inklusive Query – die Access-Logs entsprechend schützen, im Edge kein debug in CADDY_GLOBAL_OPTIONS aktivieren (das loggt die Ask-URL) und das Secret bei Verdacht rotieren (beide Seiten gleichzeitig, kurze Unterbrechung neuer Ausstellungen).
Monitoring: Uptime-Check auf eine eigene Test-Kunden-Domain (https://<test-domain>/s/<publicId>, Zertifikatsablauf-Warnung 14 Tage vorher), Container-Healthcheck des Edge (:8081/healthz, intern), Logs per docker logs pollyou-edge (JSON; „certificate obtained successfully“ bzw. „issuance denied“).
Lokal prüfen: ci/custom-domains/run.sh startet Pebble (ACME-Test-CA), pebble-challtestsrv (DNS), PostgreSQL, Redis, das Produktions-Image der API und den Edge in einem isolierten Docker-Netz (Host-Ports 8615, 8643, 8655, 8680, 8681 auf 127.0.0.1), trägt eine Domain ein, prüft, dass ohne Bestätigung kein Zertifikat ausgestellt wird, setzt TXT und CNAME, bestätigt die Domain und ruft https://umfrage.kunde.test/s/<id> und /p/<id> mit Pebbles Root als einzigem Vertrauensanker ab (200, Zertifikat von Pebble für genau diesen Host). Danach wird alles wieder entfernt (KEEP=1 lässt den Stack laufen).
5. Registry, App-Tokens und CI
- Registry: eine private Registry mit EU-Standort, z. B. die in CapRover eingebaute Registry (Cluster → „Self-hosted Registry“), GitLab Container Registry oder Scaleway. In CapRover unter Cluster → Docker Registry Configuration die Registry mit Zugangsdaten hinterlegen, damit CapRover die Images ziehen kann.
- App-Tokens: in jeder App unter Deployment → App Token ein Token erzeugen (API, Worker, Scheduler, Dashboard, Embed, Doku, optional Landing Page und Backup).
- Bitbucket-Variablen (Repository settings):
- Repository-Variablen:
REGISTRY(z. B.registry.pollyou.example/pollyou),REGISTRY_USER,REGISTRY_PASSWORD(secured),VITE_SENTRY_DSN. - Deployment-Variablen „Staging“ bzw. „Production“:
CAPROVER_URL(https://captain.staging.pollyou.example),CAPROVER_APP_TOKEN_API,CAPROVER_APP_TOKEN_WORKER,CAPROVER_APP_TOKEN_SCHEDULER,CAPROVER_APP_TOKEN_DASHBOARD,CAPROVER_APP_TOKEN_EMBED,CAPROVER_APP_TOKEN_DOCS, optionalCAPROVER_APP_TOKEN_SITE,CAPROVER_APP_TOKEN_BACKUP,CAPROVER_APP_PREFIX(Standardpollyou-stagingbzw.pollyou-production),SENTRY_AUTH_TOKEN,SENTRY_ORG,SENTRY_PROJECTS(pollyou-api pollyou-dashboard),SENTRY_URL(https://de.sentry.io),SMOKE_API_URL,SMOKE_DASHBOARD_URL,SMOKE_EMBED_URL,SMOKE_MERCURE_URL.
- Repository-Variablen:
- Pipelines (Bitbucket → Pipelines → Run pipeline → Custom):
deploy-staging: Backend- und Frontend-Gates →ci/deploy/build-images.sh(baut und pushtpollyou-backend,pollyou-dashboard,pollyou-embed,pollyou-site,pollyou-docs,pollyou-backupmit Tag<commit>undstaging) →ci/deploy/caprover-deploy.sh(API zuerst wegen der Migrationen, wartet auf/health, dann Worker, Scheduler, Dashboard, Embed, ggf. Landing Page und Doku; danach Sentry-Release und Smoke-Test).deploy-production: dasselbe mitproduction; der Deploy-Schritt muss manuell freigegeben werden.
Ohne Registry lässt sich jede App auch auf dem Server bauen: ci/deploy/caprover-tarball.sh <api|worker|scheduler|dashboard|embed|docs|backup|mercure|gotenberg|redis-queue|redis-cache> app.tar packt den committeten Stand samt passender captain-definition (deploy/caprover/*), danach npx caprover deploy -a <app> -t app.tar. API, Worker und Scheduler nutzen dasselbe Tarball (backend/ als Build-Kontext); CapRover baut die letzte Stage des Dockerfiles (prod).
6. Erstes Deployment
Datenbank, Redis und Mercure laufen, alle Variablen sind gesetzt.
Pipeline
deploy-stagingstarten (oder Tarball-Deploy wie oben).Log der API-App prüfen: Ausgabe von
doctrine:migrations:migrate(ausgeführte Migrationen bzw. „Already at the latest version“), danach „FrankenPHP started“.https://api.staging.pollyou.example/healthliefert{"status":"ok","checks":{"database":"ok"}}.Smoke-Test von einem beliebigen Rechner (legt ein Wegwerf-Konto
smoke-…@example.coman):bashAPI_URL=https://api.staging.pollyou.example DASHBOARD_URL=https://app.staging.pollyou.example \ EMBED_URL=https://embed.staging.pollyou.example MERCURE_URL=https://rt.staging.pollyou.example \ MAILPIT_URL= COMPOSE_PROJECT= ci/smoke-prod.shIm Browser unter
https://app.staging.pollyou.exampleregistrieren und anmelden.
Migrationen
Die API führt beim Start doctrine:migrations:migrate --all-or-nothing aus, wenn RUN_MIGRATIONS=1 gesetzt ist – mit DATABASE_MIGRATIONS_URL (Eigentümer), sonst mit DATABASE_URL; der Worker nie. Bei mehreren API-Instanzen ersetzt Swarm die Container beim Update nacheinander; die erste Instanz migriert, die folgenden finden nichts mehr zu tun. Beim Hochskalieren aus 0 Instanzen könnten Migrationen parallel starten – dafür vorher auf 1 Instanz gehen. Status und manuelle Ausführung:
bash
docker exec -it $(docker ps -qf name=srv-captain--pollyou-staging-api | head -n1) \
php bin/console doctrine:migrations:statusMigrationen müssen abwärtskompatibel sein (erst erweitern, im nächsten Release aufräumen), damit ein Rollback des Codes ohne Datenbank-Rollback möglich bleibt.
FrankenPHP-Worker-Modus
Die API läuft im Worker-Modus: der Symfony-Kernel bleibt zwischen Requests im Speicher (Symfony Runtime 7.4 unterstützt FrankenPHP nativ). Zustandsbehaftete Dienste setzt Symfonys services_resetter nach jedem Request zurück; im Projekt betrifft das u. a. TenantContext, RowLevelSecurityContext und RequestIdProvider (alle ResetInterface); die Datenbank-Sitzungsvariablen der Row-Level Security setzt die Anwendung vor jeder Anweisung neu, eine wiederverwendete Verbindung trägt also nichts in den nächsten Request, Doctrine leert den Entity Manager und schließt Verbindungen nach 10 Minuten Leerlauf (idle_connection_ttl). Neue Dienste mit Request-Zustand müssen deshalb ResetInterface implementieren. Bei Auffälligkeiten (Daten aus fremden Requests, Speicherwachstum) FRANKENPHP_WORKER_MODE=0 setzen – dann arbeitet FrankenPHP klassisch mit OPcache-Preloading.
7. Backups und Restore-Test
Der Sidecar pollyou-backup (deploy/backup) erzeugt täglich um 03:15 UTC einen pg_dump im Custom-Format, prüft ihn mit pg_restore --list, verschlüsselt ihn optional mit age und lädt ihn per rclone in einen S3-kompatiblen Speicher mit EU-Standort (Hetzner Object Storage) oder per SFTP in eine Hetzner Storage Box. Ältere Dateien als BACKUP_RETENTION_DAYS werden gelöscht.
App pollyou-staging-backup (kein Web, 1 Instanz), Image aus der CI (CAPROVER_APP_TOKEN_BACKUP) oder per Tarball:
| Variable | Beispiel / Hinweis |
|---|---|
DATABASE_URL | Eigentümer des Schemas wie DATABASE_MIGRATIONS_URL der API (nicht pollyou_app: pg_dump muss alle Zeilen lesen; Query-Parameter werden ignoriert) |
BACKUP_REMOTE | hetzner:pollyou-backups/staging |
RCLONE_CONFIG_HETZNER_TYPE | s3 |
RCLONE_CONFIG_HETZNER_PROVIDER | Other |
RCLONE_CONFIG_HETZNER_ENDPOINT | https://fsn1.your-objectstorage.com |
RCLONE_CONFIG_HETZNER_ACCESS_KEY_ID | Zugangsschlüssel |
RCLONE_CONFIG_HETZNER_SECRET_ACCESS_KEY | Secret |
BACKUP_AGE_RECIPIENT | öffentlicher age-Schlüssel (age1…); privater Schlüssel nur offline/Passwort-Manager |
BACKUP_SCHEDULE | Standard 15 3 * * * (UTC) |
BACKUP_RETENTION_DAYS | Standard 14 |
BACKUP_HEARTBEAT_URL | Heartbeat-URL des Uptime-Monitors (wird nach jedem erfolgreichen Backup aufgerufen) |
Storage Box statt Object Storage: RCLONE_CONFIG_BOX_TYPE=sftp, RCLONE_CONFIG_BOX_HOST=uXXXXX.your-storagebox.de, RCLONE_CONFIG_BOX_PORT=23, RCLONE_CONFIG_BOX_USER=uXXXXX, RCLONE_CONFIG_BOX_PASS=$(rclone obscure '<passwort>'), BACKUP_REMOTE=box:pollyou/staging.
Sofortiges Backup und Liste:
bash
docker exec $(docker ps -qf name=srv-captain--pollyou-staging-backup) backup-entrypoint backup
docker exec $(docker ps -qf name=srv-captain--pollyou-staging-backup) backup-entrypoint listRestore-Test
ci/backup/restore-test.sh spielt ein Backup in einen temporären PostgreSQL-17-Container ein (pg_restore --exit-on-error) und prüft Tabellen, Migrationsstand sowie die Anzahl von Nutzern, Organisationen und Umfragen. Er läuft auf jedem Rechner mit Docker:
bash
# neuestes Backup direkt aus dem Speicher (gleiche RCLONE_CONFIG_*-Variablen wie der Sidecar)
export BACKUP_REMOTE=hetzner:pollyou-backups/staging RCLONE_CONFIG_HETZNER_TYPE=s3 …
BACKUP_IMAGE=registry.pollyou.example/pollyou/pollyou-backup:staging \
BACKUP_AGE_IDENTITY_FILE=~/keys/pollyou-backup.txt RESTORE_TEST_MIN_USERS=1 \
ci/backup/restore-test.sh --latest
# oder eine lokale Datei
ci/backup/restore-test.sh pollyou-20261010T031500Z.dumpDen Restore-Test nach der Einrichtung einmal und danach monatlich ausführen und hier protokollieren:
| Datum | Umgebung | Backup-Datei | Ergebnis | Durchgeführt von |
|---|---|---|---|---|
| – | – | – | – | – |
Wiederherstellung im Ernstfall
API, Worker und Scheduler in CapRover auf 0 Instanzen setzen.
Backup holen und ggf. entschlüsseln (
age --decrypt --identity key.txt -o restore.dump datei.dump.age).In einem neuen Cluster zuerst die Datenbankrollen anlegen (die Policies nennen
pollyou_app/pollyou_worker) und danach dieLEAKPROOF-Anweisungen von dort wiederholen – beides ist nicht Teil des Dumps. Dann einspielen (über das CapRover-Overlay-Netz):bashdocker run --rm -it --network captain-overlay-network -v "$PWD:/in" postgres:17-alpine \ pg_restore --clean --if-exists --no-owner --no-privileges --exit-on-error \ -d "postgresql://pollyou:…@srv-captain--pollyou-staging-db:5432/pollyou" /in/restore.dumpAPI (startet mit
RUN_MIGRATIONS=1), Worker und Scheduler (genau 1) wieder hochfahren, Smoke-Test ausführen.
8. Rollback
- Code: In CapRover unter Deployment → Version History die vorherige Version der App erneut ausrollen, oder für alle Apps den vorigen Commit deployen:
IMAGE_TAG=<alter-commit> ci/deploy/caprover-deploy.sh(die Images bleiben in der Registry). Reihenfolge wie beim Deployment: API, Worker, Scheduler, Dashboard, Embed. Ein Rollback auf eine Version vor dem eigenen Scheduler-Container lässt den Scheduler wieder im Worker laufen: dann die Worker-App auf 1 Instanz und die Scheduler-App auf 0 setzen. - Datenbank: Migrationen werden nicht automatisch zurückgerollt. Dank abwärtskompatibler Migrationen läuft der alte Code auf dem neuen Schema. Nur wenn nötig gezielt
php bin/console doctrine:migrations:migrate previm API-Container – oder als letzter Ausweg ein Restore aus dem Backup (Datenverlust seit dem Backup!).
9. Monitoring
Statusseite, Metriken und Alerting laufen auf einem eigenen kleinen Monitoring-Server (Uptime Kuma, Prometheus, Alertmanager, Grafana) plus einem Agenten auf dem App-Server: Einrichtung in Monitoring und Statusseite, Vorgehen pro Alert im Runbook. Die folgenden Prüfungen deckt Uptime Kuma ab; was dort (noch) nicht eingerichtet ist, z. B. TLS-Ablauf, Backup-Heartbeat und Kunden-Domains, ergänzt man in Kuma von Hand oder bei einem externen Dienst.
Uptime (Uptime Kuma oder z. B. Better Stack/UptimeRobot, Intervall 30 s–1 min, Alarm per E-Mail/Slack):
| Check | Erwartung |
|---|---|
https://api.…/health | HTTP 200, Inhalt enthält "status":"ok" |
https://app.…/healthz und https://app.…/ | HTTP 200 |
https://app.…/health | HTTP 200 (Dashboard-Proxy → API) |
https://embed.…/healthz | HTTP 200 |
https://rt.…/healthz | HTTP 200 |
Heartbeat Backup (BACKUP_HEARTBEAT_URL) | täglich, Karenzzeit 2 Stunden |
| TLS-Zertifikate aller vier Subdomains | Warnung 14 Tage vor Ablauf |
https://<Test-Kunden-Domain>/s/<publicId> | HTTP 200, Zertifikat gültig (TLS-Edge) |
Die Docker-Healthchecks der Images (API: /health, Dashboard/Embed: /healthz) lassen Swarm hängende Container automatisch neu starten.
Fehler-Tracking: Sentry (EU-Region). Jedes Image trägt sein Release (pollyou@<12 Zeichen Commit>): das Backend über SENTRY_RELEASE, das Dashboard über VITE_SENTRY_RELEASE. Die Deploy-Pipeline legt das Release an, verknüpft Commits (sofern die Repository-Integration eingerichtet ist), finalisiert es und meldet ein Deployment für die Umgebung. Die Sentry-Projekte müssen PII ausschließen (Backend sendet keine IPs/Cookies).
Logs: API, Worker und Scheduler loggen JSON nach stderr (CapRover → App → Logs oder docker service logs).
Redis und PostgreSQL: Die Queue-Instanz verweigert bei maxmemory (3 GB) neue Nachrichten, statt welche zu verwerfen – used_memory (redis-cli INFO memory) und die Queue-Länge (XLEN messages) beobachten; ein wachsender Rückstand heißt „mehr Worker“. redis-cli LATENCY LATEST zeigt Persistenz-Spitzen (beide Instanzen melden Ereignisse > 25 ms). PostgreSQL protokolliert Checkpoints, Sperren > 1 s und Abfragen > 1 s; häufige Meldungen checkpoints are occurring too frequently bedeuten ein zu kleines max_wal_size.
Lokale Produktionsprüfung
docker-compose.prod.yml (Projektname pollyou-prodcheck) baut die Produktions-Images und startet API, zwei Worker (WORKER_REPLICAS), Scheduler, Dashboard, Embed, PostgreSQL (mit deploy/postgres/20-pollyou-tuning.sql), Redis für die Queue und Redis für den Cache (deploy/redis/*.conf), Mercure und Mailpit mit Produktionseinstellungen – unabhängig vom Dev-Stack:
| Dienst | URL |
|---|---|
| API | http://localhost:9480 |
| Dashboard | http://localhost:9573 |
| Embed | http://localhost:9574 |
| Mercure | http://localhost:9481 |
| Mailpit | http://localhost:9482 |
bash
make prod-check # alles in einem Durchgang, räumt danach auf
make prod-up # nur starten (zum Ausprobieren im Browser)
make prod-smoke # ci/smoke-prod.sh
make prod-backup-test # Backup des prodcheck-DB in .prodcheck/backups + Restore-Test
make prod-down # stoppen, Volumes löschenDie Datenbank legt beim ersten Start die Rollen pollyou_app und pollyou_worker an (deploy/postgres/10-pollyou-roles.sh); die API migriert als Eigentümer und läuft als pollyou_app, der Worker als pollyou_worker, beide mit DATABASE_ROLE_STRICT=1.
Der Smoke-Test prüft u. a.: /health, keine Stacktraces und kein Profiler, CORS nur für die Dashboard-Origin, Migrationsstand, die Datenbankrollen von API und Worker (Row-Level Security), Sicherheits-Header und Caching des Dashboards (Brotli/gzip, immutable für gehashte Assets, 404 für fehlende Assets, SPA-Fallback), Registrierung mit gelöstem Captcha, Login, Refresh-Cookie-Attribute, /api/me, Refresh, die Auswertung von X-Forwarded-For hinter vertrauenswürdigen Proxies, die Zustellung der Bestätigungs-Mail durch den Worker, eigene Redis-Consumer-Namen je Worker, genau einen Scheduler, die Persistenz-Einstellungen beider Redis-Instanzen und von PostgreSQL sowie Embed- und Mercure-Health.