Skip to content

Architecture

cs-lewis-backend is one Python service. It runs Django 5.2, Wagtail, and Django REST Framework (DRF) on AWS ECS (EC2 launch type), and serves both the Flutter mobile app and the editorial CMS from the same codebase. The data stores are Aurora Serverless v2 (PostgreSQL), Redis (ElastiCache), and S3 fronted by CloudFront for media. User identity is external. The mobile client signs in against Firebase Authentication (passwordless email-link), and the API validates the Firebase ID token on requests that need a user.

This document describes what is built and how it is wired. For the reasoning behind each choice, see ADR-0001.

System overview

                 Internet
                    │
              ┌─────▼─────┐
              │ Cloudflare│  (WAF + DDoS + rate limit)
              └─────┬─────┘
                    │
         ┌──────────┴──────────┐
         │                     │
         ▼                     ▼
   ┌──────────┐         ┌────────────┐
   │CloudFront│         │    ALB     │
   │ (media)  │         │            │
   └────┬─────┘         └─────┬──────┘
        │                     │
        ▼                     ▼
   ┌──────────┐    ┌──────────────────────┐      Flutter app
   │    S3    │    │  ECS on EC2 (ASG)    │          │ sign-in
   │  audio + │    │  ┌──────────────┐    │          ▼
   │  images  │    │  │ Django (web) │    │  ← Mobile API (DRF)   ┌──────────┐
   └──────────┘    │  └──────────────┘    │    + Wagtail Admin    │ Firebase │
                   │  ┌──────────────┐    │    + Django Admin     │   Auth   │
                   │  │Celery (soon) │    │                       └────┬─────┘
                   │  └──────────────┘    │                            │ JWKS
                   └─────┬───────────┬────┘◄───── validate ID token ───┘
                         │           │
                         ▼           ▼
                   ┌──────────┐ ┌────────────┐
                   │  Aurora  │ │ElastiCache │
                   │Serverless│ │   Redis    │
                   │v2 (PG)   │ │            │
                   └──────────┘ └────────────┘
                                      │
                              ┌───────▼────────┐
                              │   External     │
                              │  • Bedrock     │
                              │  • ElevenLabs  │
                              │  • RevenueCat  │
                              │  • Substack    │
                              │  • Sentry      │
                              └────────────────┘

One web tier sits behind Cloudflare (WAF, DDoS, and rate limit) for the API and CloudFront for media. CDN-fronting of the API is deferred to Phase 2. Redis at origin is enough at MVP scale. Postgres holds the content graph and user data. Redis caches API responses, caches the Firebase JWKS, and holds rate-limit counters. S3 holds binary media. Auth is off-box: the Flutter client signs in against Firebase Authentication, and the API validates the Firebase ID token (RS256, verified against Firebase's JWKS) on protected requests. The backend stores no passwords and runs no sign-in UI.

Celery is not provisioned, and the project does not yet depend on it. The diagram shows the intended worker service, added when the first async workload lands.

Services and components

Django web service (ECS on EC2)

The process is granian serving the Django WSGI application, one container per task on EC2-backed ECS. Networking is bridge mode with dynamic host ports behind the ALB target group. Liveness and health are served by django-alive at /-/alive/ (bare liveness for the ALB) and /-/health/ (database and cache).

Four surfaces run inside this process:

  • Mobile API at /api/ for the Flutter contract, with OpenAPI via drf-spectacular. The API uses DRF with function-based views and a thin read/action layer (see ADR-0004). There is no version segment in the path. The client selects the version with the Accept header (see ADR-0005).
  • Wagtail admin for editor authoring, snippet content, drafts, and publish workflows.
  • Django admin for user management, read-only support inspection of reader data (reflections, bookmarks, entitlements, the RevenueCat event log), and operational tooling.
  • Web routes served at the site root by interfaces/web: the deep-link association files (Universal Links and App Links) under .well-known/, and the sign-in landing page. These are code-defined contract endpoints, not editor-managed Wagtail pages.

Both admin route prefixes are env-configurable (WAGTAIL_ADMIN_URL and DJANGO_ADMIN_URL, with non-obvious defaults studio/ and backstage/) so the login surfaces are not the bot-scanned /cms/ and /admin/.

Code follows Django RAPID architecture: horizontal layers, not vertical apps. src/data/ holds every model and migration. src/readers/ holds read-only business logic. src/actions/ holds state-changing logic. src/interfaces/ holds the DRF API, the CMS admin, the CLI, and the web routes. The API views call the reader and action functions directly. There is no dependency-injection container.

The service scales horizontally. The ASG scales the EC2 fleet on CPU, ECS service autoscaling scales the task count, and the ALB fans out across tasks. It reads from Postgres (Django ORM) and Redis (cache), and writes to Postgres, Redis, and Sentry.

Celery worker service (ECS on EC2), deferred

Celery is not provisioned. The IaC ships the web service only, and celery is not yet a dependency. This section describes the intended shape when async work is added. At that point, add the worker and beat services, add a Redis broker connection, and add celery to the app.

Planned jobs:

  • ElevenLabs audio generation per published passage.
  • Scheduled publishing, if a Daily Drop scheduler lands (Phase 2).

Periodic jobs do not wait on Celery. A schedule is a scheduled_tasks: entry in .aws/iac/config/<env>/compute.yaml, which EventBridge Scheduler turns into a one-off ECS task running one management command on the app image. reconcile_entitlements is the first, daily. Use this path for anything on a clock; keep Celery for work a request must hand off.

AI tagging does not run at request time or as a Celery job today. It runs off-repo during book processing (see Content pipeline).

Postgres (AWS Aurora Serverless v2, managed)

Postgres holds Wagtail content (snippets, drafts, revisions), the Django models (users, content graph, audit), and the tag and theme relations used for traversal. Tags and themes are many-to-many relations, not Postgres array columns, so overlap ranking runs in the ORM and in Python over a bounded candidate set (see Reading-screen onward rails).

The engine is Aurora PostgreSQL 17, Serverless v2, auto-scaling by ACU. Dev floors at 0.5 ACU (scales toward 0 when idle) single-AZ. Prod floors at 1 ACU (never scale-to-zero) with a reader in a second AZ for failover and 7-day backup retention. Both environments run the same engine, so there is no dev/prod drift.

The pg_trgm extension is available for fuzzy text matching. pgvector is a Phase 2 option for semantic similarity if it earns a place. Read-heavy, spiky traffic maps well to ACU auto-scaling, and read replicas are a one-line add for Phase 2. See ADR-0002.

Redis (AWS ElastiCache, managed)

Redis holds the API response cache, the Firebase JWKS cache (24-hour TTL), and rate-limit counters. When Celery lands, Redis also serves as the broker and result backend. Auth is stateless, so Redis holds no session or token denylist.

The deployment is a single node at MVP (cache.t4g.micro in dev, cache.t4g.small in prod). Replica or cluster mode is a Phase 2 option.

S3 and CloudFront

S3 holds ElevenLabs-generated audio, content images, and editor uploads. The bucket itself blocks all public access; a CloudFront distribution reads it through origin access control. The CI build injects that distribution's domain into the task definition as AWS_S3_CUSTOM_DOMAIN, and the app emits plain CDN URLs whenever it is set. Left empty, the app falls back to presigned S3 URLs, so a deploy of the storage stack alone does not flip the mode: the next CI build does. URLs are unsigned at MVP, since all content is guest-accessible; signed URLs with a days-long expiry arrive with journey gating.

Content model

The content graph is a set of Wagtail snippets and Django models, not Wagtail Pages (see ADR-0008). Every table, link and constraint is listed in the data model reference, generated from the models.

  • Passage: the atom. A standalone Lewis quote, read, listened to, and reflected on without opening its source. Carries the verbatim body, an editor-authored interpretation, an optional reflection_prompt, an audio document, themes, tags, and a primary_theme. Drawn from exactly one origin: a Book or a Writing.
  • Writing: a full readable Lewis unit, discriminated by kind (letter, essay, or chapter). One model, not three (see ADR-0006).
  • Book: the Work. A reference and purchase object, not read in-app. Passages and Writings point to it through a single source FK. Carries an external buy link (buy_cta), a discovery handoff, not monetization.
  • Article: editorial writing by a named contributor, distinct from Lewis's canon. Imported from Substack as drafts.
  • Journey and JourneyChapter: an editor-curated ordered course through Lewis's work. Models and CMS authoring exist (CSL-70). The Journey API is not built yet.
  • Theme: a world a reader enters, with a palette and portal assets synced from the design library. Membership is a deliberate editorial act, not a tag.
  • TagCategory and Tag: the editor-defined, category-scoped taxonomy (for example the category "Emotional Register" with its values). Tags drive the traversal graph.
  • Home: a singleton settings record for the Home page. Holds the Theme of the Week, the What's New pins, and the Featured Books list.

API surface

Reference: docs/api/index.md covers auth, errors, pagination, caching, and images. Exact schemas are in Swagger at /api/docs/ in non-production environments.

Every request carries a shared app key in the X-API-Key header. A missing or unknown key returns 403 with error_type: InvalidAPIKey (see ADR-0005). The app key identifies the app, not the user.

User identity is a Firebase ID token sent as Authorization: Bearer <token>. FirebaseAuthentication is the global default auth class, so any view gets user auth with plain IsAuthenticated. Content reads are guest-open (app key only). Endpoints that act on a user require the bearer token and return 401 without one.

The RevenueCat webhook (POST /api/payments/revenuecat/webhook/) is the one endpoint outside both gates. RevenueCat calls it server-to-server, so it carries neither the app key nor a Firebase token. It verifies RevenueCat's own shared Authorization token, plus an optional HMAC signature, by hand instead (see Subscriptions).

Built endpoints:

Endpoint Purpose
GET /api/config/ App config and advisory API version
GET /api/account/, DELETE /api/account/ Signed-in profile (JIT-provisioned, carries the reader's store entitlements) and account deletion
GET /api/account/entitlements/, POST /api/account/entitlements/refresh/ The reader's store entitlements, and an on-demand resync from RevenueCat (throttled 5/min)
POST /api/payments/revenuecat/webhook/ RevenueCat subscription events; RevenueCat-authenticated, syncs the affected reader's entitlements
POST /api/auth/dev-login/ Dev/QA token, gated by DEV_AUTH_ENABLED, 404 in prod
GET /api/themes/, /{slug}/, /{slug}/pieces/, /{slug}/opening-passages/, /featured/ Theme list, detail, pieces carousel, opening passages, featured
GET /api/passages/, /{pk}/, /todays-featured/ Passage list, detail, and the Home hero pool
GET /api/writings/{pk}/, /api/articles/{pk}/ Reading-screen detail for a Writing or Article
GET /api/books/featured/ Home Featured Books
GET /api/whats-new/, /api/further-reading/ Home sections

Public reads pair a short public cache window with an ETag. The server sets Cache-Control: public, max-age=<n> (API_PUBLIC_CACHE_SECONDS, default 30s, env-tunable) and a weak ETag. Within the window a client or CDN serves from cache with no round-trip. After it lapses, the client revalidates with If-None-Match, which returns 304 when unchanged. Content images are served as a rendition set (one URL per standard width, WebP, content-addressed), generated when an editor saves the piece (see ADR-0009).

Request flows

Guest read, Home hero

  1. Mobile client goes through Cloudflare and the ALB to the mobile API.
  2. GET /api/passages/todays-featured/ returns up to 10 live passages in the editor's Home quote queue order, falling back to newest-published first when the queue holds no eligible passage — empty, or every entry unpublished or blank-bodied. The order is fixed either way, so the response is cacheable and consistent.
  3. The client shows one passage per local calendar day and passes ?exclude=<ids> it already has once it has walked them. The server returns the next ones and wraps around at the end. There is no server-side per-user state — the day boundary is the client's (CSL-315).
  4. The response is cached under the public window and revalidated via the ETag.
  5. The client fetches the audio and image renditions directly from CloudFront URLs.

The rest of the Home feed loads through its own endpoints (/api/whats-new/, /api/books/featured/, /api/further-reading/, and the Theme of the Week), each with its own cache TTL. The client loads the hero first and the rest lazily, so there is no server-side aggregate call.

Reading-screen onward rails

When a reader opens a passage, writing, or article, the reading screen shows two computed carousels (readers/related.py):

  1. "More on this topic" pulls up to 10 live Writing units that share the piece's source Book or its themes. Ranking is same-book first, then shared-theme count, then newest.
  2. "Related content" pulls up to 10 live contributor Article units that share the piece's tags, with a theme-intersection fallback when the piece carries no tags. Ranking is by shared count.

The candidate set is bounded, so both rails filter with ORM __in queries and rank in Python. There is no search engine and no raw array-overlap SQL at MVP. If this ranking becomes a bottleneck, Phase 2 can move it behind a search backend without changing call sites.

Editor publish, content pipeline

Content reaches the app through an offline pipeline, not a runtime AI call (see ADR-0007 and docs/guides/book-processing.md).

  1. A book is processed off-repo: deterministic segmentation extracts verbatim passages, an editorial pass selects them, and an AI pass writes the interpretation around each quote. Lewis's words are never AI-altered.
  2. The result is committed as a two-file YAML contract (book.yaml and excerpts.yaml).
  3. The import_book management command creates the Book and draft Passage snippets (live=False), with AI provenance stored in ingest_meta.
  4. An editor reviews each draft in the Wagtail admin, edits the interpretation and tags, and publishes.
  5. The API serves only live content, so the AI layer stays withheld until an editor publishes.

Substack articles follow the same shape through the import_articles command, which creates draft editorial Article snippets. Both importers are CLI commands run on demand, not Celery jobs.

Data stores

Store Holds Backup and DR
Aurora Serverless v2 (PostgreSQL) Wagtail content and revisions, Django models (including reader entitlements and the subscription event log), tag and theme relations Automated backups and PITR (7-day retention in prod); reader in a second AZ for failover
Redis (ElastiCache) API response cache, Firebase JWKS cache, rate-limit counters; Celery broker and results once Celery lands Cache loss tolerated (rebuilt on miss); no durable state at MVP
S3 Media (audio, images, uploads) Object versioning enabled; cross-region replication TBD for prod

See the data model reference for the tables this app declares. Wagtail, Django and django-taggit add their own alongside them.

Network topology

  • VPC: one per environment, with public and isolated subnets across two AZs.
  • Public subnets: the ALB and the ECS EC2 hosts. Instances get public IPs (NAT-free to save cost). Outbound traffic to Bedrock, ElevenLabs, Substack, and Sentry goes through the Internet Gateway.
  • Isolated subnets: Aurora and ElastiCache Redis, with no internet route.
  • Security groups: the ALB reaches the ECS hosts on ephemeral host ports (bridge networking); ECS reaches Aurora on 5432 and Redis on 6379. The ALB security group restricts ingress to Cloudflare's published IP ranges, so the origin cannot be reached directly.
  • DNS: Cloudflare (proxied) for the public domain cslewis.com; a private Route53 zone for stable internal hostnames.
  • TLS: Cloudflare to ALB in SSL Full (strict) mode, with an ACM public cert on the ALB and TLS 1.2 or higher. See ADR-0002 and deployment.md.

Environments

Environment Purpose Differences from prod
Local (dev) Developer workstation Docker Compose runs Django, Postgres, and Redis; sqlite and locmem fallbacks let runserver work with zero setup
Dev (AWS) Shared cloud dev at dev-cslewis.fueled.engineering Single EC2 host, Aurora single-AZ (min 0.5 ACU), no WAF tuning yet, separate Sentry environment. Live in account 987292390939 (us-east-2). HTTPS is live (ACM cert on the ALB, Cloudflare Full strict); /-/health/ returns healthy
Production Deployed since 2026-08-25, serving app.cslewis.com; public launch (~Oct 2026) moves it to cslewis.com Multi-AZ Aurora (min 1 ACU plus reader) with PITR, autoscaling ECS and ASG, WAF enabled, full Sentry, CloudFront enabled

Infrastructure as code

Choice Notes
Tool AWS CDK (TypeScript) Reused from Fueled's copa-backend scaffold; typed constructs; single cdk deploy flow
Layout Four stacks: shared (ECR and GitHub OIDC), network (VPC), compute (ECS/EC2, ALB, CodeBuild, IAM), storage (Aurora, Redis, S3) Per-env config in config/{common,dev,prod}/*.yaml
Location .aws/iac/ in this repo One repo keeps app code and infra moving together; a PR can span both

See ADR-0002 for the tool and compute decision, and deployment.md for the deploy and rollback runbook.

External dependencies

Service Used for Phase Failure handling
Cloudflare Edge for the public API: DNS for cslewis.com, WAF, DDoS protection, rate limit, and the TLS proxy to the ALB MVP Inline on every API request; the origin is locked to Cloudflare IP ranges, so it cannot be bypassed. A Cloudflare outage blocks the public API
AWS Bedrock (Claude) AI interpretation and tag suggestions during offline book processing; narration-script cue drafting in the CMS Audio Studio MVP Offline job / editor-session call; never in the API response path. Studio degrades to plain-text scripts with a banner
ElevenLabs Narration generation (cloned voice) in the CMS Audio Studio MVP, built in the CMS Runs in a background thread, not the editor's request (ADR-0014), with a 240 s provider timeout; the section card polls and shows progress, then a Retry on failure. Readers only ever get stored, approved audio
Substack Article import into the CMS MVP import_articles CLI, idempotent per article; import failures are non-fatal
Bookstore affiliate links Purchase handoff MVP Stored as buy_cta on Book; no runtime dependency
HarperCollins Content rights (no Narnia audiobook audio) MVP No rights flag exists yet; whether short Narnia passages may be narrated is an open rights question
Firebase Authentication Passwordless (email-link) identity (see ADR-0003); Flutter signs in, the backend validates the Firebase ID token (JWKS/RS256) V1 Token validation cached against the Firebase JWKS; a dev-login HS256 escape hatch for local and dev. Apple and Google sign-in deferred
App Store / Play Store Account deletion (V1); in-app purchase via RevenueCat (V1) V1 Deletion removes the Firebase identity first, then the local row; the endpoint must be production-tested before App Store submission
RevenueCat Store-of-record over Apple and Google in-app purchase; the webhook plus the v2 REST customer sync (active entitlements + subscriptions) feed the entitlement store V1 A webhook 500 triggers a RevenueCat retry; a daily scheduled reconciliation corrects stalled retries for existing subscribers (a lost first purchase is covered by the app refresh call and a periodic --all sweep). A sync failure never grants access
Sentry Error tracking MVP Soft dependency; the app continues if Sentry is unreachable
AWS (RDS, ElastiCache, S3, CloudFront, ECS, EventBridge Scheduler, Secrets Manager) Managed services MVP AWS SLAs; multi-AZ for the prod data tier

Security architecture

Concern Mitigation
Admin surface exposure Admin login routes are not the well-known /admin/ and /cms/; prefixes are env-configurable (DJANGO_ADMIN_URL, WAGTAIL_ADMIN_URL) with non-obvious defaults, set to unguessable values in prod
API gate Every /api/ request needs a valid X-API-Key; a bad key fails closed with 403 (see ADR-0005)
CSRF (admin) Django CSRF middleware on all admin POSTs
XSS Django template auto-escaping; Wagtail rich-text sanitisation
SQL injection Django ORM parameterised queries; no raw SQL on user input
Secrets AWS Secrets Manager and SSM Parameter Store; never in code or images
Transport TLS 1.2 or higher via the ALB and CloudFront; HSTS set by Django
Rate limiting Redis-backed counters on write endpoints; WAF rate limit on the public API
Payments webhook The RevenueCat webhook sits outside the app-key and Firebase gates; it verifies RevenueCat's shared Authorization token and an optional HMAC-SHA256 signature (5-minute replay window) and fails closed with 401
Auth (V1) Firebase Authentication, passwordless (see ADR-0003). Flutter owns sign-in; the backend validates the Firebase ID token (JWKS/RS256). Stateless bearer, no server session and nothing to revoke. Accounts are JIT-provisioned on the first valid, email-verified token
Account deletion (V1) Dedicated endpoint. Removes the Firebase identity with the caller's own token, then hard-deletes the local row. Must be production-tested before App Store submission
PII handling V1 PII is limited to email and name; encryption at rest via RDS
Audit Wagtail page and revision history on content edits; Django admin log on user actions; structured request logs

Scaling characteristics

Tier At MVP Headroom
ALB Auto-managed n/a
Django web (ECS/EC2) 1 task on 1 host (dev); prod 2 to 4 tasks across 2 to 4 hosts, autoscale on CPU Horizontal scale to dozens; Phase 2 may split read/write tiers
Celery workers Not provisioned Add worker and beat services when the first async job lands
Postgres Aurora Serverless v2 (dev 0.5 to 2 ACU; prod 1 to 8 ACU plus reader) ACU auto-scales with load; read replicas a one-line add in Phase 2
Redis cache.t4g.micro (dev), cache.t4g.small (prod) Vertical scale first; cluster mode in Phase 2 if needed
S3 / CloudFront Auto-managed n/a

Performance targets

Surface p95 target Notes
Home hero (/api/passages/todays-featured/) < 100 ms (cache hit) / < 300 ms (cache miss) Deterministic pool; cache window plus ETag
Home sections and reading detail < 200 ms (cache hit) / < 500 ms (cache miss) Onward rails rank in Python over a bounded set
Editor publish to reader visibility < 30 s Bounded by the public cache window plus revalidation
Audio playback start < 1 s Narration is generated and approved in the CMS, then served CloudFront-cached

Open items

  • Public launch: the production stacks are deployed and serve app.cslewis.com, but the environment is content-prep until launch. The primary domain cslewis.com has DNS on Cloudflare and is not yet the app's hostname.
  • Account-deletion strategy (V1): FK on-delete strategies to finish; must be production-tested before App Store submission.
  • Journey API: models and CMS authoring exist; the reader-facing endpoints are not built.
  • Audio batch runs: the studio narrates one target at a time in the editor's session. A bulk generate_audio command and Celery are intentionally not built (dropped 2026-09-07): the workflow is editors approving a shortlisted set through the studio, and ElevenLabs' 5-concurrent cap means a queue would add infra, not throughput.
  • Audio URL signing: media serves from CloudFront with unsigned URLs. Signing (a key group, a private key in Secrets Manager, and a seven-day expiry bucketed to the day) is deferred to the paywall, so expires_at is null on every track until then.
  • Subscription reconciliation: built (payments Slice 2) — a daily reconcile_entitlements job via EventBridge Scheduler that re-checks existing subscribers for lifecycle changes a dropped webhook missed (a lost first purchase relies on the app refresh call, or a periodic --all sweep), plus a read-only support-inspection admin for entitlements and the event log. Defined in IaC (compute-stack); activates on the next cdk deploy cslewis-compute-<env>.
  • Content gating: entitlements are stored and exposed, but the journey gate that reads them (locked preview, listen 403, free-Journey claim) is a separate track (CSL-356) and not merged.
  • DMCC renewal notices: the UK renewal-reminder job against each subscription's expiry is planned (CSL-383) and not built; required before paid launch in the UK.