Skip to content

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
VITE_API_URL=https://api.subscribeflow.com

Build Command:

Bash
cd apps/web && bun run build

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
1
2
3
4
5
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install uv && uv sync
CMD ["uv", "run", "uvicorn", "subscribeflow.main:app", "--host", "0.0.0.0", "--port", "8000"]

Environment Variables:

Bash
# Database & Cache
DATABASE_URL=postgresql://...
REDIS_URL=redis://...

# Authentication
JWT_SECRET_KEY=...
API_KEY_SALT=...
AUDIT_EMAIL_HMAC_KEY=...  # Keyed HMAC for subscriber-deletion audit entries (ADR-007 §4)

# Proxy & Network (Production Requirement)
TRUST_PROXY_HEADERS=true  # Erforderlich hinter dem Fly-Proxy für korrektes IP-Whitelisting und IP-Rate-Limiting.
                          # Ohne diese Einstellung ist request.client.host die Proxy-Adresse;
                          # gesetzte IP-Whitelists würden dann gegen diese prüfen und jeden aussperren.
                          # Lokal in .env.example auf false für schnelle Entwicklung.

# Resend (Email)
RESEND_API_KEY=re_...
DEFAULT_SEND_DOMAIN=mail.subscribeflow.net

# Session Cookie
SESSION_COOKIE_DOMAIN=.subscribeflow.net

# Stripe (Billing)
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_STARTER=price_...
STRIPE_PRICE_PROFESSIONAL=price_...

# Observability (Specula, nach ratums ADR-012 — ein anderes Dokument als
# dieses Repos eigenes ADR-012) — siehe "Monitoring & Observability" unten
OTEL_EXPORTER_ENDPOINT=https://otel-collector.specula.internal
SPECULA_TEAM_API_KEY=...
SPECULA_TRACES_SAMPLE_RATE=0.0

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
uv run celery -A subscribeflow.worker worker --loglevel=info

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
uv run celery -A subscribeflow.workers.celery_app:celery_app beat --loglevel=info

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)

YAML
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - run: uv sync
      - run: uv run pytest

  deploy-backend:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: flyctl deploy --remote-only

  deploy-frontend:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

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_KEY müssen auf subscribeflow-api (App- und Worker-Prozessgruppe) gesetzt werden, SENTRY_DSN/SENTRY_AUTH_TOKEN entfernt. CI und der Docker-Build brauchen ausserdem Zugriff auf das private specula-client-python-Repo (Git-Dependency, siehe pyproject.tomls [tool.uv.sources]) — dafür fehlt aktuell ein Secret in diesem Repository. Ausserdem fehlt die serverseitige Trigger-Regel für specula_signal_type=operational_alert (siehe Tabelle oben) — ohne sie erreicht jeder report_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)