Deployment Architecture¶
Subscribe Flow follows a modern cloud-native architecture with a clear separation of frontend (static SPA) and backend (API).
Deployment Diagram¶
graph TB
subgraph "Client Layer"
Browser[Browser]
Mobile[Mobile App]
SDK[SDK Client]
end
subgraph "CDN / Static Hosting"
CF[Cloudflare Pages]
PC[Preference Center SPA]
AD[Admin Dashboard SPA]
CF --> PC
CF --> AD
end
subgraph "API Layer - Railway/Fly.io"
LB[Load Balancer]
API1[FastAPI Instance 1]
API2[FastAPI Instance 2]
Worker[Celery Worker]
Beat[Celery-Beat Scheduler]
LB --> API1
LB --> API2
end
subgraph "Data Layer - Managed Services"
PG[(PostgreSQL 15)]
Redis[(Redis 7)]
end
subgraph "External Services"
Resend[Resend API]
Stripe[Stripe API]
Webhooks[Customer Webhooks]
end
Browser --> CF
Mobile --> CF
SDK --> LB
PC --> LB
AD --> LB
API1 --> PG
API2 --> PG
API1 --> Redis
API2 --> Redis
Worker --> PG
Worker --> Redis
Worker --> Resend
Beat --> Redis
API1 --> Resend
API2 --> Resend
API1 --> Stripe
API2 --> Stripe
API1 --> Webhooks
API2 --> Webhooks
Resend -.Webhook.-> LB
Stripe -.Webhook.-> LB
style CF fill:#f97316
style LB fill:#3b82f6
style PG fill:#8b5cf6
style Redis fill:#ef4444
style Resend fill:#10b981
style Stripe fill:#6366f1
style Worker fill:#f59e0b
style Beat fill:#f59e0b Components¶
Frontend (Cloudflare Pages / Netlify)¶
Technology: React 19 SPA with Vite 6+ (Bun runtime)
Deployment: - Static build (bun run build) - Automatic deployment on Git push (main branch) - Global CDN distribution - HTTPS by default - Custom domain support
Environment Variables:
| Bash | |
|---|---|
Build Command:
| Bash | |
|---|---|
Output Directory: apps/web/dist
Backend (Railway / Fly.io)¶
Technology: Python FastAPI with Uvicorn
Deployment: - Docker container - Auto-scaling (2-10 instances) - Health checks (/health) - Zero-downtime deployments
Dockerfile:
| Docker | |
|---|---|
Environment Variables:
Auf allen drei Backend-Prozessen setzen — API, Celery-Worker und Celery-Beat. Ein Prozess ohne beide Werte meldet nichts und sieht dabei genauso gesund aus wie einer, der meldet.
Celery Worker (Railway / Fly.io)¶
Technology: Celery with Redis backend
Tasks: - Resend API synchronization - Webhook delivery - Data retention cleanup - Email sending - Magic link email delivery
Deployment: - Separate container (same codebase) - Auto-scaling based on queue length
Command:
| Bash | |
|---|---|
Celery-Beat Scheduler (Railway / Fly.io)¶
Technology: Celery-Beat with Redis backend
Scheduled Tasks: (Quelle: workers/celery_app.py::beat_schedule)
| Task | Schedule | Description |
|---|---|---|
reset_monthly_email_counters | 1st of month, 00:00 UTC | Resets emails_sent_this_month to 0 for all organizations |
send_daily_idea | Täglich, IDEALAB_DAILY_SEND_HOUR:15 UTC | idealab „Idee des Tages"-Broadcast. Selbst feature-geflaggt: ohne IDEALAB_DAILY_URL und IDEALAB_ORG_ID ein No-op (TF-447). |
Command:
| Bash | |
|---|---|
Deployment: - Eigene Fly-Prozessgruppe beat (siehe fly.toml), 256 MB - Genau eine Instanz — fly scale count beat=1. Beat führt nichts aus, es enqueued nur; eine zweite Instanz stellt jeden Task doppelt in die Queue, ohne dass etwas fehlschlägt.
Database (Supabase / Railway Postgres)¶
Technology: PostgreSQL 15+
Configuration: - Connection pooling (PgBouncer) - Automated backups (daily) - Point-in-time recovery - Read replicas (post-MVP)
Scaling: - Vertical scaling (CPU/RAM) - Read replicas for analytics - Connection pooling (max 100 connections)
Cache (Upstash Redis / Railway Redis)¶
Technology: Redis 7+
Use Cases: - Session storage (Magic Link tokens, JWT sessions) - API response caching - Celery message queue + Beat schedule store - Per-organization rate limiting counters
Configuration: - Maxmemory policy: allkeys-lru - Persistence: RDB + AOF - Eviction: Automatic
Custom Domain DNS Configuration¶
Professional-tier organizations can configure custom email domains. The following DNS records must be set by the customer:
| Record Type | Name | Value | Purpose |
|---|---|---|---|
| TXT | _resend.custom-domain.com | (provided by Resend) | Domain verification |
| MX | custom-domain.com | (provided by Resend) | Inbound email routing |
| TXT | custom-domain.com | v=spf1 include:... | SPF authentication |
| CNAME | resend._domainkey.custom-domain.com | (provided by Resend) | DKIM signing |
Domain verification is handled via the DomainService, which calls the Resend API to register and verify domains.
Deployment Pipeline¶
CI/CD (GitHub Actions)¶
Documentation Deployment¶
Subscribe Flow betreibt zwei separate Dokumentations-Pipelines:
| Dokumentation | Ziel | URL | Workflow |
|---|---|---|---|
| Intern (vollständig) | GitHub Pages | talent-factory.github.io/subscribe-flow/ | Standard MkDocs Deploy |
| Kunden (öffentlich) | Cloudflare Pages | docs.subscribeflow.net | .github/workflows/docs-public.yml |
Die Kunden-Dokumentation wird über Cloudflare Pages deployt und enthält nur die öffentlich relevanten Seiten (Guides, API-Referenz, SDK). Die interne Dokumentation auf GitHub Pages umfasst zusätzlich Architektur-Details, Entwickler-Guides und ADRs.
Monitoring & Observability¶
Specula (Error Tracking & Log Forwarding, nach ratums ADR-012)¶
Seit TF-928 löst Specula (specula-client-python, das flottenweite, selbstgehostete Talent-Factory-Monitoring aus ADR-012 (ratum)) Sentry ab. Anders als bei Sentry (US-SaaS, wo die konkrete Ingest-Region — ingest.de.sentry.io — explizit gewählt werden musste, ADR-005) ist der Collector selbstgehostetes Talent-Factory-Infra statt eines Drittanbieters; die konkrete Hosting-Region ist damit eine Infra-Entscheidung von Talent Factory und nicht mehr Teil dieses Repos oder seiner Konfiguration — sie ist hier bewusst nicht dokumentiert, weil eine falsch kopierte Region schlimmer wäre als gar keine Angabe.
Backend (implementiert): core/observability.py::init_observability (FastAPI-Prozess, api/app.py::create_app()) und init_worker_observability (Celery-Worker/-Beat, workers/celery_app.py's worker_init, worker_process_init, beat_init) — plus core/observability.py::reattach_log_handler an after_setup_logger, notwendig, weil Celerys eigenes Logging-Setup sonst den gerade angehängten Log-Handler wieder entfernt (siehe Modul-Docstring von celery_app.py). Letzterer initialisiert bewusst nur den Log-Handler neu, nicht das Tracing — das ist zu diesem Zeitpunkt schon (oder gleich) passiert, ein erneuter init_tracing()-Aufruf wäre nur verschwendeter Aufwand. Jeder Prozess initialisiert für sich — der Worker forkt, und ein vor dem Fork erzeugter Client meldet nichts mehr.
Frontend: noch keine Fehler-Erfassung, unabhängig von dieser Migration.
Konfiguration¶
| Variable | Bedeutung |
|---|---|
OTEL_EXPORTER_ENDPOINT | Specula-OTel-Collector-Endpunkt. Leer = Observability vollständig aus. |
SPECULA_TEAM_API_KEY | Team-API-Key, als Authorization-Header an den Collector gesendet. |
SPECULA_TRACES_SAMPLE_RATE | Anteil der Requests mit Performance-Tracing (Default 0.0, also aus). |
Beide der ersten beiden Variablen müssen gesetzt sein — es gibt bewusst kein zweites ENABLE_OBSERVABILITY-Flag, das man vergessen könnte zu setzen.
SpeculaLogHandler scrubt PII anders als das alte Sentry-SDK nicht selbst — core/observability.py::_EmailScrubbingFilter (core/privacy.py::scrub_emails) maskiert deshalb alles, was wie eine Adresse aussieht (a***@example.com), im gerenderten Log-Text und in jedem specula_-präfigierten extra-Feld, bevor ein Record den Handler erreicht. Erkennung über die Form des Werts, nicht über den Feldnamen — eine Namensliste deckt nur die Aufrufstellen ab, die es beim Schreiben gab. SpeculaLogHandler grenzt das Risiko zusätzlich strukturell ein: nur extra-Felder mit einem specula_- Präfix werden als Attribute exportiert — eine Logzeile mit einer Adresse in einem anderen Feld (workers/tasks/feedback_email_tasks.py loggt to=<Adresse> auf ERROR) verlässt den Prozess dadurch gar nicht erst.
Diese Schicht ist Absicherung, kein Ersatz für die Regel in core/alerting.py: In report_alert gehören ausschliesslich Identifikatoren.
Alert-Rules (Trigger-Regeln)¶
Anders als bei Sentry entscheidet nicht das SDK, welcher Log-Eintrag "aktionierbar" ist: SpeculaLogHandler leitet jeden ERROR+-Record an den Collector weiter (siehe core/observability.py::_attach_log_handlers Docstring), aber nur Records mit einem passenden specula_signal_type lösen eine der im otel-collector-Repo (specula, nicht dieses Repo) konfigurierten Trigger-Regeln aus, die per Telegram zustellen:
specula_signal_type | Gesetzt in | Bedeutung |
|---|---|---|
unhandled_exception | api/app.py's globaler Exception-Handler | Unbehandelte Exception in einem Request. |
celery_task_failure | workers/celery_app.py's task_failure-Signal-Handler | Ein Celery-Task ist endgültig fehlgeschlagen. |
worker_heartbeat | workers/tasks/system_health_tasks.py::_log_heartbeat | Lebenszeichen alle 2 Minuten — ein Ausbleiben dieses Signals (nicht sein Auftreten) ist das eigentliche Alert-Kriterium; erfordert einen serverseitig in Specula konfigurierten Check, siehe Modul-Docstring. |
operational_alert | core/alerting.py::report_alert (jeder Aufruf) | Deckt jeden aktuellen und künftigen report_alert-Aufrufort mit einer einzigen Trigger-Regel ab, statt pro alert_event eine eigene Regel zu brauchen; das konkrete alert_event steht zusätzlich als eigenes specula_alert_event-Feld sowie im Nachrichtentext. Offener Punkt: die serverseitige Trigger-Regel für operational_alert selbst ist noch nicht angelegt (siehe unten) — bis dahin erreicht der Record den Collector, löst aber noch keine Telegram-Meldung aus. |
core/alerting.py::report_alert trägt seinen alert_event sowohl im gerenderten Nachrichtentext (alert_event=<event>) als auch, seit der Review zu PR #152, als eigenes specula_alert_event-Feld neben specula_signal_type=operational_alert — ohne dieses Tag wäre ein report_alert-Aufruf so untaggt wie ein einfacher logger.error() und könnte von keiner Trigger-Regel gematcht werden, genau das TF-929-Muster, das diese Migration schliessen soll. Anders als Sentrys scope.fingerprint gibt es weiterhin kein client-seitiges Gruppieren mehrerer Vorkommen in ein Issue — jeder report_alert-Aufruf ist ein eigener, durchsuchbarer Log-Eintrag.
alert_event | Bedeutung | Reaktion |
|---|---|---|
retention.clock_miss | touch_activity hat keine Zeile getroffen — ein Subscriber war aktiv, seine Retention-Uhr steht still. Ohne Eingriff wird der Datensatz nach 24 Monaten gelöscht, obwohl die Person gehandelt hat. | Sofort: betroffene subscriber_id / organization_id aus dem Nachrichtentext prüfen, last_activity_at manuell setzen, Ursache (Mandanten-Verwechslung oder Race mit einer Löschung) klären. |
Offener Punkt (Deployment, nicht Code):
OTEL_EXPORTER_ENDPOINT/SPECULA_TEAM_API_KEYmüssen aufsubscribeflow-api(App- und Worker-Prozessgruppe) gesetzt werden,SENTRY_DSN/SENTRY_AUTH_TOKENentfernt. CI und der Docker-Build brauchen ausserdem Zugriff auf das privatespecula-client-python-Repo (Git-Dependency, siehepyproject.tomls[tool.uv.sources]) — dafür fehlt aktuell ein Secret in diesem Repository. Ausserdem fehlt die serverseitige Trigger-Regel fürspecula_signal_type=operational_alert(siehe Tabelle oben) — ohne sie erreicht jederreport_alert-Aufruf den Collector, pagt aber niemanden.
Metrics (Prometheus + Grafana)¶
- API response time
- Database query performance
- Celery queue length
- Resend API rate limits
- Stripe webhook processing time
Logs (Structured Logging)¶
- JSON format
- Correlation IDs
- Log levels (DEBUG, INFO, WARNING, ERROR)
Disaster Recovery¶
Backup Strategy¶
- Database: Daily automated backups (7-day retention)
- Redis: Not critical (can be rebuilt)
- Code: Git repository (GitHub)
Recovery Time Objective (RTO)¶
- Database: < 1 hour
- Backend: < 15 minutes (redeploy)
- Frontend: < 5 minutes (CDN cache invalidation)
Recovery Point Objective (RPO)¶
- Database: < 24 hours (daily backup)
- Redis: Acceptable data loss (cache)