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  │
                              │  • 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 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).

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 (once the pipeline lands), content images, and editor uploads. Access is read-public at MVP, since all content is guest-accessible. Signed URLs are a Phase 2 option for tier-gated audio. CloudFront sits in front of S3 with a long TTL on immutable content keyed by content hash.

Content model

The content graph is a set of Wagtail snippets and Django models, not Wagtail Pages (see ADR-0008). The canonical field-level schema is in docs/design/cms-architecture.md.

  • 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-overview.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.

Built endpoints:

Endpoint Purpose
GET /api/config/ App config and advisory API version
GET /api/account/, DELETE /api/account/ Signed-in profile (JIT-provisioned) and account deletion
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 a deterministic pool of up to 10 live passages, newest-published first, so the response is cacheable and consistent.
  3. The client shows one passage per session and passes ?exclude=<ids> it already has. The server returns the next ones and wraps around once all are seen. There is no server-side per-user state.
  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, 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 docs/design/cms-architecture.md for the content data model and field schemas.

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 Public launch (~Oct 2026) at cslewis.com Multi-AZ Aurora (min 1 ACU plus reader) with PITR, autoscaling ECS and ASG, WAF enabled, full Sentry, CloudFront enabled. Config staged in config/prod/ but not yet deployed

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 MVP Offline job; failures re-run before the YAML contract is committed. No runtime dependency
ElevenLabs Audio pre-generation (cloned voice) MVP, not built Planned as a Celery job; audio is non-blocking (text-only fallback)
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 audio) MVP Enforced via a rights flag; chapter audio gated at the API
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); IAP (Phase 2) V1 and Phase 2 Deletion removes the Firebase identity first, then the local row; the endpoint must be production-tested before App Store submission
Sentry Error tracking MVP Soft dependency; the app continues if Sentry is unreachable
AWS (RDS, ElastiCache, S3, CloudFront, ECS, 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
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 CloudFront-cached, pre-generated once the pipeline lands

Open items

  • Production deploy: config/prod/ is staged but not deployed. The primary domain is cslewis.com, with DNS on Cloudflare (confirmed).
  • 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 pipeline: the ElevenLabs Celery job and Celery itself are not provisioned.