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 viadrf-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 theAcceptheader (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 verbatimbody, an editor-authoredinterpretation, an optionalreflection_prompt, anaudiodocument,themes,tags, and aprimary_theme. Drawn from exactly one origin: aBookor aWriting.Writing: a full readable Lewis unit, discriminated bykind(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 singlesourceFK. 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.JourneyandJourneyChapter: 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.TagCategoryandTag: 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¶
- Mobile client goes through Cloudflare and the ALB to the mobile API.
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.- 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. - The response is cached under the public window and revalidated via the ETag.
- 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):
- "More on this topic" pulls up to 10 live
Writingunits that share the piece's sourceBookor itsthemes. Ranking is same-book first, then shared-theme count, then newest. - "Related content" pulls up to 10 live contributor
Articleunits that share the piece'stags, 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).
- 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.
- The result is committed as a two-file YAML contract (
book.yamlandexcerpts.yaml). - The
import_bookmanagement command creates theBookand draftPassagesnippets (live=False), with AI provenance stored iningest_meta. - An editor reviews each draft in the Wagtail admin, edits the interpretation and tags, and publishes.
- 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 iscslewis.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.
Related¶
docs/adr/0001-backend-architecture.md: decision rationale and alternativesdocs/design/cms-architecture.md: the content data modeldocs/guides/deployment.md: deployment pipeline, IaC layout, rollbackdocs/api-overview.md: API conventions (auth, errors, pagination, caching, images)