Architecture
How HyperSaaS is built: the processes, how requests flow, where data lives, and the stack, with the reasons behind it.
HyperSaaS is two applications: a Django backend (API, Celery worker and beat) and a Next.js frontend. In production they run as containers on AWS; see Deployment for how.
The system at a glance
flowchart LR
user(["Browser"])
stripe(["Stripe"])
subgraph aws["AWS"]
alb["Application Load Balancer<br/>HTTPS, routes by host name"]
subgraph ecs["ECS on Fargate"]
frontend["frontend<br/>Next.js"]
web["web<br/>Django + gunicorn"]
worker["worker<br/>Celery + Docling"]
beat["beat<br/>Celery beat"]
end
rds[("RDS PostgreSQL 17<br/>+ pgvector")]
valkey[("ElastiCache Valkey<br/>queues and cache")]
s3[("S3 bucket<br/>private")]
secrets["Secrets Manager"]
end
ai(["AI providers<br/>OpenAI, Anthropic, Gemini"])
mail(["Resend<br/>email"])
tools(["Tool APIs<br/>SerpApi, WeatherAPI, Google Maps"])
user -- "staging.example.com" --> alb
user -- "uploads, downloads<br/>(signed URLs)" --> s3
stripe -- "webhooks" --> alb
alb -- "app host" --> frontend
alb -- "api host" --> web
frontend -- "API calls, server side<br/>(through the load balancer)" --> alb
web --> rds
web --> valkey
web --> s3
web --> ai
web --> tools
web --> mail
valkey -- "tasks" --> worker
beat -- "scheduled tasks" --> valkey
worker --> rds
worker --> s3
worker --> ai
secrets -. "keys, when a task starts" .-> ecs
- One load balancer serves both host names. Requests for the API host go to Django, and everything else goes to Next.js. It handles HTTPS with a free AWS certificate and redirects HTTP to HTTPS.
- The browser only talks to Next.js, and to S3 through signed URLs. Next.js calls Django on the server side with the signed-in user's token, so tokens never reach browser JavaScript.
- Django (
web) serves the API, the admin and Stripe's webhooks. Streamed chat replies come from here as server-sent events. - The Celery worker does the slow work: reading documents with Docling, embedding them, and reading chat attachments. It's the only process with Docling and PyTorch.
- Celery beat queues scheduled tasks. There's always exactly one.
- PostgreSQL holds everything, including the vectors for document search (pgvector).
- Valkey (an open-source fork of Redis) carries Celery's queues and Django's cache.
- S3 holds uploaded documents and chat attachments. The bucket is private; files go in and out through short-lived signed URLs.
The processes
Everything runs as containers on ECS Fargate, from two backend images and one frontend image:
| Process | Image | Starts with | Does | How many |
|---|---|---|---|---|
web | backend-web | /start (gunicorn, 8 threads) | API, admin, webhooks, streamed chat | 1 or more |
worker | backend-worker | /start-celeryworker | Documents, attachments, summaries, on the queues celery and attachments | 1 or more |
beat | backend-web | /start-celerybeat | Scheduled tasks (the schedule is kept in the database) | exactly 1 |
frontend | frontend | node server.js | The Next.js app | 1 or more |
migrate | backend-web | manage.py migrate | Runs once per deploy, then stops | one-off |
Each process has its own CPU and memory, its own logs (/ecs/hypersaas-staging/<process> in CloudWatch), and its own health check:
- web: the load balancer calls
/healthz/, which checks the database and the cache. - frontend: the load balancer calls
/api/health. - worker: ECS sends it a Celery ping every minute, after a 3-minute start period.
How requests flow
Signing in, and every API call after it. Django issues the tokens. Next.js keeps them in its encrypted session cookie and adds them to each call it makes for the user.
sequenceDiagram
autonumber
participant B as Browser
participant N as Next.js (frontend)
participant D as Django (web)
B->>N: Email and password (or a 6-digit email code)
N->>D: allauth headless sign-in
D-->>N: Two-factor needed
N-->>B: Ask for the authenticator code
B->>N: Code
N->>D: Complete the sign-in
D-->>N: Access token (15 min) and refresh token (14 days)
N-->>B: Encrypted session cookie (no tokens in JavaScript)
B->>N: Open a page
N->>D: API call with the access token
D-->>N: JSON, checked against a zod schema
N-->>B: Rendered page
Note over N,D: When the access token expires, Next.js swaps the refresh token for a new pair.
A streamed chat reply. The reply streams from Django to the browser through Next.js while it's being generated.
sequenceDiagram
autonumber
participant B as Browser
participant N as Next.js
participant D as Django (web)
participant G as LangGraph agent
participant P as AI provider
participant T as Tool (search, weather, knowledge base)
B->>N: Send message
N->>D: POST the message (streamed)
D->>D: Check plan, model and credit
D->>G: Run the agent with the chat history
G->>P: Model call
P-->>G: Tokens, or a tool call
G->>T: Run the tool
T-->>G: Result
G->>P: Model call with the result
P-->>G: Tokens
G-->>D: Events
D-->>N: Server-sent events
N-->>B: Streamed text
D->>D: Save the reply and the usage (credit)
Documents and the knowledge base. Files go straight from the browser to S3, the worker reads and indexes them, and chat searches them.
sequenceDiagram
autonumber
participant B as Browser
participant D as Django (web)
participant S as S3
participant W as Worker
participant DB as PostgreSQL
participant O as OpenAI
B->>D: Ask to upload (name, type, size)
D-->>B: Signed upload URL
B->>S: PUT the file
B->>D: Confirm the upload
D->>W: Queue documents.process_document
W->>S: Read the file
W->>W: Docling: text, tables, layout, OCR
W->>W: Split into chunks (up to 512 tokens)
W->>O: Embed the chunks
W->>DB: Store chunks, vectors and search text
Note over B,DB: Later, in a chat
D->>O: Embed the question
D->>DB: Vector search and keyword search, merged
D->>O: Rerank the best 20 (optional)
D-->>B: Answer with citations
Chat attachments (PDF, Word, PowerPoint, Excel) are read by the worker too. They have their own attachments queue, so they never wait behind document indexing. If no worker answers within 60 seconds, the web process reads the file itself with lighter parsers.
Background and scheduled work
| Task | Queue | Time limit | What it does |
|---|---|---|---|
documents.process_document | celery | 10 min (11 hard) | Reads, chunks and embeds an uploaded file; retried up to 3 times |
documents.process_url_document | celery | 2 min (3 hard) | The same for a web page or a YouTube transcript |
chat.read_attachment | attachments | 2 min (2.5 hard) | Extracts the text of a chat attachment |
chat.summarize_chat_history | celery | the default (1 min soft) | Summarises long chats so they fit the model's context |
clear-expired-sessions | celery | the default | Daily at 04:00, from beat |
The worker runs 2 processes by default (CELERY_WORKER_CONCURRENCY), because each process reading a document with Docling needs a few gigabytes of memory.
Where data lives
| Store | Holds | Notes |
|---|---|---|
| PostgreSQL 17 (RDS) | Users, workspaces, chats, messages, plans and subscriptions, documents and their chunks, vectors, Celery beat's schedule | pgvector with an HNSW index, plus a full-text search column. Daily backups, kept 7 days. Encrypted. |
| Valkey 8 (ElastiCache) | Celery's queues and results, Django's cache, cached sessions, rate limits | TLS between the tasks and Valkey, encrypted at rest. Losing it loses queued tasks, not data. |
| S3 | Uploaded documents, chat attachments | Private and encrypted. Chat attachments expire after a day. The browser reads and writes through signed URLs. |
| The web image | Static files (the admin's CSS and JavaScript) | Served by WhiteNoise, and versioned with the code. |
| Secrets Manager | Generated secrets (Django's secret key, the JWT signing key, the admin path, the database password) and third-party keys | Read by ECS when a task starts. |
The stack, and why
Backend
| Piece | Version | What it does here | Why |
|---|---|---|---|
| Python | 3.12 | Runtime | Supported by every dependency, including Docling and PyTorch. |
| Django | 5.2 LTS | Web framework, ORM, migrations, admin | Supported until April 2028. The admin, migrations and security middleware are hard to rebuild well. |
| Django REST Framework + drf-spectacular | 3.16 / 0.28 | The JSON API and its OpenAPI schema | The standard for Django APIs; the schema documents every endpoint. |
| django-allauth (headless, MFA) | 65.19 | The whole account lifecycle: sign-up, email verification, sign-in, email sign-in codes, two-factor (TOTP and recovery codes), sessions | One maintained library for all of it, with an API mode built for a separate frontend. |
| JWT (from allauth) | RS256 | Tokens for the frontend | Access tokens last 15 minutes; refresh tokens last 14 days and change on every use. Tokens are checked against the server-side session, so signing out or changing the password ends them at once. |
| Celery + django-celery-beat | 5.5 / 2.9 | Background tasks and schedules | Docling can take minutes on a long PDF, which can't happen inside a request. Beat's schedule lives in the database, so you can change it in the admin. |
| gunicorn (threaded) | 23 | Serves Django | 8 threads per process and a 120-second timeout, so a streamed reply doesn't block a whole process. |
| WhiteNoise | 6.12 | Serves static files from the web image | No public bucket is needed, and static files always match the running code. |
| psycopg | 3.2 | PostgreSQL driver | The current driver, compiled against libpq. |
| django-storages (S3) | 1.14 | Files in S3 | Signed URLs, with credentials from the task's role. |
| django-anymail (Resend) | 13.0 | Transactional email | Switch providers by changing one setting. |
| Stripe | 12.0 | Subscriptions | Checkout and the customer portal keep card handling with Stripe. Plans are code (subscriptions/plans.py), and sync_stripe_plans makes Stripe match them. |
| Sentry | 2.25 | Error reports | Optional; on when SENTRY_DSN is set. |
AI and documents
| Piece | Version | What it does here | Why |
|---|---|---|---|
| LangGraph | 0.4 | The chat agent: model calls, tool calls, streaming | A small, explicit state machine for the tool loop, with streaming built in. |
| LangChain provider packages | 0.3 | OpenAI, Anthropic and Google Gemini behind one interface | Models from three providers, chosen per plan and per chat. The default model is the plan's choice if its provider has a key; otherwise it's the cheapest model whose provider does. |
| Langfuse | 4.15 | Traces of every chat turn and tool call | Optional; on when both of its keys are set. |
| Docling (+ RapidOCR) | 2.126 | Reads PDF, Word, PowerPoint and Excel, keeping tables and layout, with OCR for scans | The best open-source reader for layout-heavy files. Only the worker image has it. |
| PyMuPDF, python-docx, python-pptx, openpyxl | — | Lighter readers in the web image | The fallback when no worker is available to read an attachment. |
| trafilatura, youtube-transcript-api | — | Web pages and YouTube transcripts into the knowledge base | |
| OpenAI embeddings | text-embedding-3-small, 1536 dimensions | Vectors for document chunks | Good quality for the price. The knowledge base needs an OpenAI key, even if chat uses another provider. |
| pgvector (HNSW) + PostgreSQL full-text search | 0.3 (client) | Hybrid search | A vector search and a keyword search run in the same database and are merged with reciprocal rank fusion. Both meaning and exact terms (names, codes) count. |
| LLM reranker | gpt-4o-mini, 20 candidates | Reorders the merged results before the answer | Configurable through DOCUMENT_RERANKER and the other DOCUMENT_* settings. |
Why the vectors live in PostgreSQL rather than a separate vector database:
- there's one system to run, back up and secure
- document permissions are ordinary database queries
- chunks are saved in the same transaction as their document
- the keyword half of hybrid search is built into PostgreSQL
Frontend
| Piece | Version | What it does here | Why |
|---|---|---|---|
| Next.js (App Router) | 16 | The app, marketing pages, blog and docs | Server rendering and server-side API calls keep tokens on the server. output: "standalone" gives a small Node server for the container. |
| React | 19 | UI | |
| Auth.js | 5 (beta) | Sessions: keeps Django's tokens in an encrypted cookie and refreshes them | The standard for Next.js, and it works with any backend. |
| zod | 4 | Checks every backend response against a schema | A changed API fails loudly at the boundary instead of deep in a component. |
| Tailwind CSS + Base UI components | 4 | Styling and accessible components | |
| AI Elements | — | Chat UI pieces (messages, the prompt input, branches) | |
| react-hook-form, TanStack Table, cmdk, sonner, motion | — | Forms, tables, the command menu, toasts, animation | |
| Content collections (MDX) | — | Blog posts and docs in src/content | |
| Vitest | 5 | Tests | |
| pnpm, Node | 9.15, 22 | Package manager, runtime |
The frontend image reads its settings when it starts (BACKEND_API_URL, NEXTAUTH_URL, AUTH_SECRET), so one image serves every environment. Production builds call the backend over HTTPS only. That's why, on AWS, the frontend calls the API host through the load balancer rather than an internal address.
The agent
Chats run through a pluggable handler (backend/chat/handlers/):
- LangGraph: the agent, with tool calls and streamed replies.
- None: plain chat on the same models, without tools.
- Custom: subclass
BaseAgentHandlerto plug in another framework.
Project structure
backend/
├── backend/
│ ├── chat/ chat sessions, messages, the agent, streaming
│ ├── core/ health checks, rate limits, tracing, check_keys
│ ├── documents/ knowledge base: ingestion, embeddings, hybrid search
│ ├── users/ accounts, two-factor
│ ├── workspaces/ multi-tenancy, AI usage metering
│ └── subscriptions/ plans and Stripe billing
├── config/ settings, URLs, the API router, Celery
├── compose/ Dockerfiles and start scripts (images: web and worker)
├── infra/terraform/ AWS: bootstrap, the environment module, staging
├── docs/ the architecture and deployment guide, plans, runbooks
└── .github/workflows/ CI, Release, Deploy, Images, Terraform
frontend/
├── src/
│ ├── app/ Next.js App Router pages and API routes
│ ├── components/ UI components
│ ├── lib/ API client, auth, validation (zod), utilities
│ └── config/ site configuration
├── Dockerfile standalone Next.js on node:22-alpine
└── .github/workflows/ CI, Release, Deploy