DEV Community

Tanweer Ahmad
Tanweer Ahmad

Posted on Originally published at learnbyai.app

How I Built an AI Document Chat App with Next.js, FastAPI & RAG (Self-Hosted on Oracle Free Tier)

Six months ago, I had an idea, a laptop, and a free Oracle Cloud account. Today I'm running an AI document intelligence platform that lets users upload contracts, medical reports, financial statements, research papers, and other documents, then ask questions in plain language. It even works through WhatsApp, so users don't need to install another app.

Here's what the platform looks like today:


I built it alone. Most of the infrastructure is either free or costs a few dollars a month. Here's the complete breakdown of every decision I made, every tool I chose, and what I actually learned along the way.


The idea

I wanted to build something that solved a real problem I kept noticing: professionals who receive important documents, a contract, a lab report, a financial statement, and have no quick way to understand what's actually in them without reading every word or paying an expert for a summary.

The core feature was simple to describe: upload any document, ask questions in plain language, and get grounded answers from the actual content. The implementation, as it turns out, was a lot less simple.


Infrastructure: Oracle Cloud Free Tier (seriously, it's free)

The first decision was where to run this. I didn't want to pay AWS or GCP pricing before I had a single paying user, so I dug into Oracle Cloud's Always Free tier, which is genuinely more generous than most people realise.

What I got for free:

  • 4 CPU (Ampere A1)
  • 24 GB RAM
  • 200 GB block storage
  • US East (Ashburn) region
  • Outbound bandwidth included

That's enough to run a real production stack. The catch is you have to be patient when provisioning; the free-tier capacity in popular regions fills up, and you'll hit "Out of host capacity" errors if you try at peak hours. I eventually managed to provision an instance during off-peak hours. If you choose the Always Free tier, patience can be just as important as the technical setup.

Server setup

Once I had the instance, I installed:

# Core tools
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx certbot python3-certbot-nginx \
  python3-pip python3-venv git curl ufw fail2ban \
  tesseract-ocr tesseract-ocr-eng libmagic1
Enter fullscreen mode Exit fullscreen mode

Then locked down the server:

# UFW firewall rules
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable

# fail2ban for brute force protection
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
Enter fullscreen mode Exit fullscreen mode

One thing I wish I'd done earlier: set up SSH key authentication and disabled password-based SSH before doing anything else. Do that first.


Self-hosted Supabase

I chose Supabase for auth, database, and storage. Rather than use the managed cloud version (which has a free tier but also has limits that would bite me in production), I self-hosted the entire Supabase stack using their official Docker Compose setup.

git clone --depth 1 https://github.com/supabase/supabase
cd supabase/docker
cp .env.example .env
# Edit .env with your secrets, ANSI keys, JWT settings
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Self-hosted Supabase gives you the complete stack: PostgreSQL, GoTrue (auth), PostgREST (API), Realtime, Storage, and Studio, running on your own server. The Studio dashboard is available on a local port; I keep it behind a VPN tunnel and don't expose it publicly.

Database schema design

I designed the schema around a few core entities:

-- Users (managed by Supabase Auth, extended with custom fields)
CREATE TABLE user_profiles (
  id UUID REFERENCES auth.users(id) PRIMARY KEY,
  full_name TEXT,
  plan TEXT DEFAULT 'free',
  wallet_balance DECIMAL(10,2) DEFAULT 0.00,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Document sessions
CREATE TABLE documents (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES user_profiles(id),
  filename TEXT NOT NULL,
  cloudinary_public_id TEXT,
  file_type TEXT,
  page_count INT,
  chunk_count INT,
  status TEXT DEFAULT 'processing',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Chat messages
CREATE TABLE chat_messages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  document_ids UUID[],
  user_id UUID REFERENCES user_profiles(id),
  role TEXT CHECK (role IN ('user', 'assistant')),
  content TEXT NOT NULL,
  tokens_used INT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- WhatsApp session linking
CREATE TABLE whatsapp_links (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES user_profiles(id),
  phone_number TEXT UNIQUE NOT NULL,
  linked_at TIMESTAMPTZ DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

Row-level security on everything. This is Supabase's killer feature: you define policies in SQL, and the database enforces them at the query level, so a bug in your API can't accidentally return someone else's documents.


Self-hosted Qdrant

Qdrant is a vector database written in Rust that is fast, memory-efficient, and easy to self-host. I run it as a Docker container on the same Oracle instance:

docker run -d \
  --name qdrant \
  --restart unless-stopped \
  -p 6333:6333 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant
Enter fullscreen mode Exit fullscreen mode

Each document gets its own collection in Qdrant, named after the document's UUID. When a document is deleted, the collection goes with it.

Collection structure:

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams

client = QdrantClient(host="localhost", port=6333)

client.create_collection(
    collection_name=document_id,
    vectors_config=VectorParams(
        size=1536,      # OpenAI text-embedding-3-small dimension
        distance=Distance.COSINE
    )
)
Enter fullscreen mode Exit fullscreen mode

Document pipeline: from upload to queryable vectors

This is the core of the whole system. When a user uploads a document, five things happen in sequence:

1. Upload to Cloudinary

I use Cloudinary for raw document storage rather than Supabase Storage because Cloudinary handles file serving, access control, and signed URL generation cleanly, and the free tier is generous.

import cloudinary uploader

def upload_document(file_bytes: bytes, filename: str, user_id: str):
    result = cloudinary.uploader.upload(
        file_bytes,
        resource_type="raw",
        public_id=f"documents/{user_id}/{filename}",
        use_filename=True,
        unique_filename=True,
        access_mode="authenticated"  # signed URLs only
    )
    return result["public_id"], result["secure_url"]
Enter fullscreen mode Exit fullscreen mode

2. Text extraction

For PDFs with a proper text layer, PyMuPDF (fitz) extracts text directly. For scanned documents or images, I fall back to Tesseract OCR:

import fitz  # PyMuPDF
import pytesseract
from PIL import Image
import io

def extract_text(file_bytes: bytes, file_type: str) -> str:
    if file_type == "application/pdf":
        doc = fitz.open(stream=file_bytes, filetype="pdf")
        text = ""
        for doc page:
            page_text = page.get_text()
            if len(page_text.strip()) < 50:
                # Likely scanned, render page and run OCR
                pix = page.get_pixmap(dpi=200)
                img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
                page_text = pytesseract.image_to_string(img)
            text += page_text
        return text
    # DOCX, TXT, etc handled by other extractors
Enter fullscreen mode Exit fullscreen mode

Tesseract quality depends heavily on the scan quality and DPI. 200 DPI is the minimum for reasonable accuracy; 300 DPI is better. English works well out of the box. Multi-language documents need the appropriate Tesseract language packs installed.

3. Chunking with OpenAI

I use OpenAI's API for both chunking strategy and embeddings. For chunking, rather than naive fixed-size splitting, I use a semantic approach that tries to keep related content together:

from openai import OpenAI

client = OpenAI()

def chunk_text(text: str, document_id: str) -> list[dict]:
    # Ask the model to identify natural semantic boundaries
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "Split the following document into coherent semantic chunks. "
                           "Each chunk should be self-contained and 200-500 words. "
                           "Return as JSON array of strings."
            },
            {"role": "user", "content": text[:15000]}  # context window limit
        ],
        response_format={"type": "json_object"}
    )
    chunks = json.loads(response.choices[0].message.content)["chunks"]
    return chunks
Enter fullscreen mode Exit fullscreen mode

For very long documents (100+ pages), I split them into sections first, chunk each section, then embed.

4. Embedding

def embed_chunks(chunks: list[str]) -> list[list[float]]:
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=chunks
    )
    return [item.embedding for item in response.data]
Enter fullscreen mode Exit fullscreen mode

text-embedding-3-small at 1536 dimensions is the sweet spot, better than ada-002, cheaper than text-embedding-3-large, and the quality difference for document retrieval is negligible in my testing.

5. Store in Qdrant

from qdrant_client.models import PointStruct

def store_embeddings(document_id: str, chunks: list[str], embeddings: list[list[float]]):
    points = [
        PointStruct(
            id=i,
            vector=embedding,
            payload={"text": chunk, "chunk_index": i}
        )
        for i, (chunk, embedding) in enumerate(zip(chunks, embeddings))
    ]
    client.upsert(collection_name=document_id, points=points)
Enter fullscreen mode Exit fullscreen mode

FastAPI backend

The API layer is FastAPI, running behind Nginx with Gunicorn + Uvicorn workers:

# main.py
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(title="LearnByAI API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://learnbyai.app"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
Enter fullscreen mode Exit fullscreen mode

The RAG query endpoint:

@app.post("/chat")
async def chat(request: ChatRequest, user=Depends(verify_token)):
    # 1. Embed the user's question
    query_embedding = embed_query(request.message)

    # 2. Retrieve relevant chunks from all selected documents
    all_chunks = []
    for doc_id in request.document_ids:
        results = qdrant_client.search(
            collection_name=doc_id,
            query_vector=query_embedding,
            limit=5,
            score_threshold=0.75
        )
        all_chunks.extend([r.payload["text"] for r in results])

    # 3. Build context and call OpenAI
    context = "\n\n---\n\n".join(all_chunks)
    response = openai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": f"Answer questions based ONLY on the following document content. "
                           f"If the answer isn't in the content, say so.\n\n{context}"
            },
            {"role": "user", "content": request.message}
        ]
    )

    # 4. Deduct from wallet, save to chat history
    await deduct_wallet(user.id, action="text_message")
    await save_message(user.id, request.document_ids, request.message, response)

    return {"answer": response.choices[0].message.content}
Enter fullscreen mode Exit fullscreen mode

The score_threshold=0.75 is important; without it, low-relevance chunks pollute the context and degrade answer quality noticeably.


Next.js frontend

The frontend is Next.js 15 App Router with Tailwind CSS. No component library; everything is custom-built. This was a deliberate choice: I wanted complete control over the design tokens and didn't want to fight a component library's opinions about styling.

Key architecture decisions:

  • Server Components for all static/SEO pages
  • Client Components only where interactivity is genuinely needed
  • next-mdx-remote for the blog (MDX files in /content/blog/, no CMS)
  • Per-page JSON-LD schema (Service, BlogPosting, FAQPage, BreadcrumbList)
  • Sitemap auto-generated from getAllPosts(), new blog posts appear in the sitemap automatically after deploy

The chat interface uses streaming responses via ReadableStream, so answers appear token-by-token rather than in a single block after a long wait:

// Streaming chat in the Next.js API route
export async function POST(req: Request) {
  const { message, documentIds } = await req.json();

  const stream = new ReadableStream({
    async start(controller) {
      const response = await fetch(`${FASTAPI_URL}/chat/stream`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message, document_ids: documentIds }),
      });

      const reader = response.body?.getReader();
      while (reader) {
        const { done, value } = await reader.read();
        if (done) break;
        controller.enqueue(value);
      }
      controller.close();
    },
  });

  return new Response(stream);
}
Enter fullscreen mode Exit fullscreen mode

Domain, DNS, and email

Domain: Registered on Hostinger. DNS is managed through their panel, nothing unusual here. A records pointing to the Oracle instance's public IP, CNAME for the www subdomain, and MX records for email.

SSL: Let's Encrypt via Certbot, auto-renewed by a cron job:

sudo certbot --nginx -d learnbyai.app -d www.learnbyai.app
# Auto-renewal
echo "0 12 * * * root certbot renew --quiet" | sudo tee /etc/cron.d/certbot-renewal
Enter fullscreen mode Exit fullscreen mode

Email (Brevo): I use Brevo (formerly Sendinblue) for transactional email, auth confirmations, password resets, and billing receipts. The free tier gives 300 emails/day, which is plenty for the early stage. Configuration is SMTP credentials in the Supabase auth.smtp_settings table, plus SPF/DKIM DNS records on Hostinger:

# DNS records for Brevo
TXT  @      "v=spf1 include:spf.sendinblue.com ~all"
TXT  _brevo "brevo-code:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
CNAME mail._domainkey  mail._domainkey.sendinblue.com
Enter fullscreen mode Exit fullscreen mode

WhatsApp integration

This was the most complex part. I built a webhook receiver in FastAPI that handles the WhatsApp Business Platform's message format:

@app.post("/webhook/whatsapp")
async def whatsapp_webhook(request: Request):
    body = await request.json()

    # Verify webhook signature
    signature = request.headers.get("X-Hub-Signature-256")
    if not verify_signature(body, signature):
        raise HTTPException(status_code=401)

    entry = body.get("entry", [{}])[0]
    changes = entry.get("changes", [{}])[0]
    value = changes.get("value", {})
    messages = value.get("messages", [])

    for message in messages:
        phone = message["from"]
        msg_type = message["type"]

        if msg_type == "text":
            await handle_text_message(phone, message["text"]["body"])
        elif msg_type == "document":
            await handle_document_upload(phone, message["document"])

    return {"status": "ok"}
Enter fullscreen mode Exit fullscreen mode

The handle_document_upload function downloads the document from WhatsApp's media servers using the media ID, puts it through the same extraction → chunking → embedding pipeline as the web app, and sends a WhatsApp reply when it's ready to query.

I also built an account linking flow: sending /link in the WhatsApp chat generates a one-time token, sends back a URL like https://learnbyai.app/whatsapp-link?token=abc123, and when the user clicks it while logged into the web app, it associates their phone number with their account. This was necessary to connect WhatsApp usage to the wallet and plan system.

Setting up the Meta app:

  1. Created a Meta Business account
  2. Added a WhatsApp product to a new app
  3. Set the webhook URL to https://learnbyai.app/api/webhook/whatsapp
  4. Verified the webhook with the challenge-response handshake
  5. Published the Data Deletion Instructions URL (required for app review)
  6. Got a permanent phone number assigned after completing business verification

The business verification part takes a few days and requires real company documentation. Don't leave this to the last minute.


Nginx configuration

Everything sits behind Nginx as a reverse proxy:

server {
    listen 443 ssl;
    server_name learnbyai.app www.learnbyai.app;

    ssl_certificate /etc/letsencrypt/live/learnbyai.app/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/learnbyai.app/privkey.pem;

    # Next.js frontend
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # FastAPI backend
    location /api/ {
        proxy_pass http://localhost:8000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        client_max_body_size 50M;
    }
}

# HTTP → HTTPS redirect
server {
    listen 80;
    server_name learnbyai.app www.learnbyai.app;
    return 301 https://$host$request_uri;
}
Enter fullscreen mode Exit fullscreen mode

One thing I learned the hard way: set client_max_body_size on the /api/ block or uploads silently fail with a 413 error and Nginx logs you'll only find if you're actively looking.


Using Claude to write the code

I used Claude as an AI coding assistant throughout the project. It helped me generate frontend components, refine the UI, build SEO utilities, and accelerate repetitive implementation work. I still reviewed every change, made the architectural decisions, tested the application, and debugged production issues myself. AI dramatically increased my development speed, but it didn't replace engineering judgment.

This is not the same as "AI wrote my app for me." I still had to understand what the code was doing, debug production issues, make architectural decisions, and know when the output was wrong. What changed was how fast I could move, from idea to working component went from hours to tens of minutes, which for a solo developer is genuinely significant.

The things Claude couldn't help with: server provisioning decisions, Nginx configuration quirks specific to my setup, understanding exactly why a Qdrant score_threshold mattered for answer quality, debugging a FastAPI-to-Next.js CORS issue that turned out to be a header casing problem. The architectural judgment is still mine. The code volume became much more manageable.


Pricing model: pay-as-you-go

Instead of charging a monthly subscription, LearnByAI uses a pay-as-you-go wallet. Users only pay for the AI features they actually use, making the platform more accessible for people who analyse documents occasionally rather than every day. The exact pricing may evolve, but the goal remains the same: simple, transparent pricing without locking users into subscriptions.

This fits the actual usage pattern for most document AI users, intensive during certain periods, quiet otherwise. A monthly subscription that charges the same in a slow month felt wrong. I also didn't want users to feel locked into a commitment before they'd had a chance to see whether the product was actually useful for them.

Payment processing handles JazzCash, Easypaisa, SadaPay, and bank transfer, the payment options relevant to my primary market in Pakistan.


What I'd do differently

Start with the document pipeline, not the UI. I spent the first two weeks building a polished frontend before the RAG pipeline was solid. The correct order is: pipeline → rough UI → iterate both together. A beautiful interface on top of mediocre retrieval is just a polished bad product.

Set up monitoring earlier. I ran without application-level monitoring for too long. A production error that I only discovered because a user told me would have been caught in minutes with proper alerting.

Don't underestimate the WhatsApp integration complexity. The webhook logic is straightforward; the Meta business verification, app review process, and production number provisioning take longer than the code does.

The Oracle free tier is real. I was sceptical that you could run a production product on a free cloud instance. You can, with the right stack choices. Self-hosting Supabase and Qdrant rather than paying for managed versions was the right call for this stage.


The full stack at a glance

Layer Tool
Server Oracle Cloud Always Free (4 CPU, 24GB RAM)
OS Ubuntu 24.04
Reverse proxy Nginx
SSL Let's Encrypt / Certbot
Frontend Next.js 15, Tailwind CSS
Backend API FastAPI (Python)
Database + Auth Self-hosted Supabase (PostgreSQL + GoTrue)
Vector DB Self-hosted Qdrant
Document storage Cloudinary
OCR Tesseract
Embeddings OpenAI text-embedding-3-small
LLM OpenAI GPT-4o-mini
Email Brevo (SMTP)
Domain Hostinger
WhatsApp Meta WhatsApp Business Platform
AI coding assistant Claude (Anthropic)

What it can do now

  • Chat with any PDF, Word doc, spreadsheet, or text file
  • Multi-document mode: query across several files at once
  • Specialized modes for legal, medical, financial, HR, construction, and academic documents
  • Voice chat (speech-to-text input, text response)
  • OCR for scanned documents
  • Document comparison
  • Export chat to Word or PDF
  • WhatsApp integration: send a document, ask questions, get answers, no app required
  • Pay-as-you-go wallet with no subscription required

If you're thinking about building something similar and have questions about any specific part of this stack, the Qdrant setup, the FastAPI RAG pipeline, the self-hosted Supabase configuration, or the WhatsApp webhook implementation, drop them in the comments. I'll answer everything I can.

If you're building an AI application, experimenting with Retrieval-Augmented Generation (RAG), or self-hosting your own AI infrastructure, I'd love to hear about your experience. Feel free to ask questions in the comments; I'm happy to share what worked, what didn't, and what I'd do differently next time.

You can also explore LearnByAI at learnbyai.app, where the first document upload and initial questions are free.

Top comments (0)