HyperSaaS

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:

ProcessImageStarts withDoesHow many
webbackend-web/start (gunicorn, 8 threads)API, admin, webhooks, streamed chat1 or more
workerbackend-worker/start-celeryworkerDocuments, attachments, summaries, on the queues celery and attachments1 or more
beatbackend-web/start-celerybeatScheduled tasks (the schedule is kept in the database)exactly 1
frontendfrontendnode server.jsThe Next.js app1 or more
migratebackend-webmanage.py migrateRuns once per deploy, then stopsone-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

TaskQueueTime limitWhat it does
documents.process_documentcelery10 min (11 hard)Reads, chunks and embeds an uploaded file; retried up to 3 times
documents.process_url_documentcelery2 min (3 hard)The same for a web page or a YouTube transcript
chat.read_attachmentattachments2 min (2.5 hard)Extracts the text of a chat attachment
chat.summarize_chat_historycelerythe default (1 min soft)Summarises long chats so they fit the model's context
clear-expired-sessionscelerythe defaultDaily 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

StoreHoldsNotes
PostgreSQL 17 (RDS)Users, workspaces, chats, messages, plans and subscriptions, documents and their chunks, vectors, Celery beat's schedulepgvector 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 limitsTLS between the tasks and Valkey, encrypted at rest. Losing it loses queued tasks, not data.
S3Uploaded documents, chat attachmentsPrivate and encrypted. Chat attachments expire after a day. The browser reads and writes through signed URLs.
The web imageStatic files (the admin's CSS and JavaScript)Served by WhiteNoise, and versioned with the code.
Secrets ManagerGenerated secrets (Django's secret key, the JWT signing key, the admin path, the database password) and third-party keysRead by ECS when a task starts.

The stack, and why

Backend

PieceVersionWhat it does hereWhy
Python3.12RuntimeSupported by every dependency, including Docling and PyTorch.
Django5.2 LTSWeb framework, ORM, migrations, adminSupported until April 2028. The admin, migrations and security middleware are hard to rebuild well.
Django REST Framework + drf-spectacular3.16 / 0.28The JSON API and its OpenAPI schemaThe standard for Django APIs; the schema documents every endpoint.
django-allauth (headless, MFA)65.19The whole account lifecycle: sign-up, email verification, sign-in, email sign-in codes, two-factor (TOTP and recovery codes), sessionsOne maintained library for all of it, with an API mode built for a separate frontend.
JWT (from allauth)RS256Tokens for the frontendAccess 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-beat5.5 / 2.9Background tasks and schedulesDocling 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)23Serves Django8 threads per process and a 120-second timeout, so a streamed reply doesn't block a whole process.
WhiteNoise6.12Serves static files from the web imageNo public bucket is needed, and static files always match the running code.
psycopg3.2PostgreSQL driverThe current driver, compiled against libpq.
django-storages (S3)1.14Files in S3Signed URLs, with credentials from the task's role.
django-anymail (Resend)13.0Transactional emailSwitch providers by changing one setting.
Stripe12.0SubscriptionsCheckout and the customer portal keep card handling with Stripe. Plans are code (subscriptions/plans.py), and sync_stripe_plans makes Stripe match them.
Sentry2.25Error reportsOptional; on when SENTRY_DSN is set.

AI and documents

PieceVersionWhat it does hereWhy
LangGraph0.4The chat agent: model calls, tool calls, streamingA small, explicit state machine for the tool loop, with streaming built in.
LangChain provider packages0.3OpenAI, Anthropic and Google Gemini behind one interfaceModels 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.
Langfuse4.15Traces of every chat turn and tool callOptional; on when both of its keys are set.
Docling (+ RapidOCR)2.126Reads PDF, Word, PowerPoint and Excel, keeping tables and layout, with OCR for scansThe 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 imageThe fallback when no worker is available to read an attachment.
trafilatura, youtube-transcript-api—Web pages and YouTube transcripts into the knowledge base
OpenAI embeddingstext-embedding-3-small, 1536 dimensionsVectors for document chunksGood quality for the price. The knowledge base needs an OpenAI key, even if chat uses another provider.
pgvector (HNSW) + PostgreSQL full-text search0.3 (client)Hybrid searchA 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 rerankergpt-4o-mini, 20 candidatesReorders the merged results before the answerConfigurable 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

PieceVersionWhat it does hereWhy
Next.js (App Router)16The app, marketing pages, blog and docsServer rendering and server-side API calls keep tokens on the server. output: "standalone" gives a small Node server for the container.
React19UI
Auth.js5 (beta)Sessions: keeps Django's tokens in an encrypted cookie and refreshes themThe standard for Next.js, and it works with any backend.
zod4Checks every backend response against a schemaA changed API fails loudly at the boundary instead of deep in a component.
Tailwind CSS + Base UI components4Styling 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
Vitest5Tests
pnpm, Node9.15, 22Package 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 BaseAgentHandler to 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

On this page