Architecture
System diagram
The pieces
Backend (emaw-django-app)
One Django project with 14 apps behind a single /rest/ prefix. It runs as a Docker Compose stack of six containers: django (gunicorn, 4 workers), worker (Celery), beat (Celery beat), redis, qdrant, and tunnel (cloudflared). Nothing publishes a port to the internet; all traffic arrives through the Cloudflare tunnel.
- PostgreSQL is outside the stack: a VM in dev, an Azure Database for PostgreSQL flexible server in prod.
- Redis is the cache, the Celery broker and result store, the throttle counter and the Stripe webhook de-duplication store. Caching and throttles fail open if it is down; Celery does not.
- Qdrant holds the Bible verse embeddings for meaning-based search. It starts empty and is loaded with a management command.
- Azure Blob (
emawblob, containermedia) is Django's default file storage, shared by dev and prod: cover images, generated verse audio, the embeddings parquet. - Cloudflare R2 (
emaw-public) holds website resource media (audio, video, documents) because it is served from the Cloudflare edge with no egress fees.
Frontends
Three React single-page apps and one React Native app. All of them talk only to the backend's /rest/ API with JWT bearer tokens. None of them hold secrets.
- The website is the only place accounts are created. It also mounts Stripe Elements for the store, events and donations.
- The web app receives signed-in users from the website through a one-time hand-off code; it never sees the website's tokens.
- The Admin CRM is served by a Cloudflare Worker that proxies
/rest/*to the backend so that its refresh cookie is first-party and the Cloudflare Access JWT reaches Django, which rejects staff requests that did not come through Access. - The mobile app uses the same API with tokens in the device's secure store and downloads per-version SQLite Bibles for offline reading.
Key request paths
Store purchase
Events and donations follow the same shape (pending record → Checkout Session → webhook marks paid → Celery links the CRM contact and sends email). The single Stripe webhook endpoint dispatches on metadata.app.
Website → web app hand-off
The code travels in the URL fragment so it never reaches a server log. Only origins in the backend's APP_ALLOWED_ORIGINS may exchange a code.
Staff request from the Admin CRM
Two independent logins are therefore required for staff: the Cloudflare Access Google login and the app's own account, which must be in the Django group CRM Staff or have capabilities granted.
Dev tunnel
In development the same cloudflared tunnel token can run on the dev VM or on a developer's laptop. Whoever runs the stack with that token is dev.emaw-app.com. See Backend local development for the hand-over procedure.
Cross-cutting conventions
- Deny by default. DRF's default permission is
IsAuthenticated; public endpoints opt in withAllowAny. - Privacy by hashing. App usernames are
SHA256(salt + lowercase email). Purchase history is matched to accounts byemail_hash. Logs carry user ids, never emails or tokens. - Capabilities, not roles, for staff. The CRM resolves a capability set and a scope (all, region, areas, self) per staff user; views and querysets are filtered by it.
- Everything idempotent on webhooks. Stripe events are de-duplicated in Redis for 24 hours.
- One request id.
X-Request-IDis set by middleware, logged, and surfaced on error toasts in the Admin CRM.