DEV Community

Madhu Dadi
Madhu Dadi

Posted on • Originally published at madhudadi.in on

The Monorepo That Runs 29 Services on a Single $24 VPS in Building This Blog: A Production AI Platform

The Monorepo That Runs 29 Services

In the last post, I explained why I built this platform from scratch. Now let's open the hood.

This monorepo contains two applications — a FastAPI backend and a Next.js frontend — plus shared infrastructure configuration. Everything lives in one repository, deploys via one docker-compose.yml, and runs on one $12/month VPS.

Here's the directory structure, explained layer by layer.


Top-Level Layout

blog_platform/
├── fastapi_backend/     # Python API server (29 routers, 21 models)
├── blog_frontend/       # Next.js 16 app (React 19, Turbopack)
├── nginx/               # Reverse proxy config
├── docs/                # Architecture decisions, content plans
├── conductor/           # Feature specs and bug fixes
├── docker-compose.yml   # Single-file deployment
├── docker-compose.prod.yml
└── deploy.sh            # Zero-downtime deploy script
Enter fullscreen mode Exit fullscreen mode

The two applications communicate exclusively through HTTP. The frontend never imports Python code, and the backend never references React components. The contract is the API schema — documented automatically by FastAPI's OpenAPI generation at /docs.


FastAPI Backend — fastapi_backend/

fastapi_backend/
├── app/
│   ├── main.py              # App entry, middleware, router registration
│   ├── config.py            # Settings from environment variables
│   ├── database.py          # Async SQLAlchemy engine + session factory
│   ├── dependencies.py      # Shared dependency injection (auth, db)
│   ├── core/                # Cross-cutting concerns
│   │   ├── limiter.py       # Rate limiting (slowapi + Redis)
│   │   ├── redis.py         # Redis connection pool
│   │   ├── scheduler.py     # APScheduler background jobs
│   │   ├── uploads.py       # File upload handling
│   │   └── exceptions.py    # Custom exception classes
│   ├── models/              # SQLAlchemy ORM models (21 files)
│   ├── schemas/             # Pydantic request/response schemas
│   ├── routers/             # API endpoint handlers (29 files)
│   └── services/            # Business logic layer
├── alembic/                 # Database migrations
├── tests/                   # Pytest test suite
├── uploads/                 # User-uploaded images, PDFs, data files
├── scripts/                 # Maintenance scripts
├── requirements.txt
├── Pipfile
└── Dockerfile
Enter fullscreen mode Exit fullscreen mode

Entry Point — main.py

The application is assembled in main.py. Here's the skeleton:

app = FastAPI(title="Madhu Dadi  AI, Python & Analytics Hub API", lifespan=lifespan)

app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.ALLOWED_HOSTS)
app.add_middleware(CORSMiddleware, ...)
app.add_middleware(SessionMiddleware, ...)

V1 = "/api/v1"
app.include_router(auth.router,        prefix=V1)
app.include_router(posts.router,       prefix=V1)
app.include_router(series.router,      prefix=V1)
app.include_router(comments.router,    prefix=V1)
# ... 25 more routers
Enter fullscreen mode Exit fullscreen mode

The lifespan context manager initializes Redis and starts the background scheduler on startup, then tears them down on shutdown.

Settings — config.py

All configuration comes from environment variables via Pydantic's BaseSettings:

class Settings(BaseSettings):
    APP_NAME: str = "Madhu Dadi API"
    DEBUG: bool = False
    DATABASE_URL: str
    REDIS_URL: str
    SECRET_KEY: str
    CORS_ORIGINS: list[str]
    ALLOWED_HOSTS: list[str]
    # ... 30+ more settings
Enter fullscreen mode Exit fullscreen mode

No hardcoded secrets. No .env files committed to git. Every deployment environment (dev, staging, production) supplies its own values through environment variables or Docker secrets.

Database — database.py

Async SQLAlchemy 2.0 with session-per-request pattern:

engine = create_async_engine(settings.DATABASE_URL, pool_size=20, max_overflow=10)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionLocal() as db:
        yield db
Enter fullscreen mode Exit fullscreen mode

The expire_on_commit=False is intentional — it prevents lazy-loading issues after commit, which is a common pitfall in async SQLAlchemy.


The 29 Routers

Every API endpoint lives in app/routers/. Here's what each one does:

Router Endpoints Purpose
auth.py 6 Register, login, Google OAuth, token refresh, logout, email verification
posts.py 8 CRUD posts, list with filters, get by slug, toggle publish
series.py 5 CRUD series, list, get with progress, next/prev navigation
comments.py 4 Create, list (tree), delete (own), admin delete
tags.py 5 CRUD tags, list, merge duplicates
bookmarks.py 4 Add, remove, list user bookmarks, check status
progress.py 4 Mark read, get user progress, series progress, completion stats
search.py 2 Full-text search, hybrid vector search
admin.py 15 Post management, user management, analytics, tasks
gamification.py 6 XP leaderboard, badges, milestones, streak, level-up
rag.py 2 Ask AI (RAG query), get related chunks
code.py 1 Execute Python code (Pyodide sandbox)
payments.py 4 Stripe checkout, webhook, subscription status, plans
referral.py 3 Create referral code, track, leaderboard
srs.py 4 Spaced repetition review queue, submit review, stats
quiz.py 4 Generate quiz, submit answers, get history, leaderboard
challenge.py 3 Daily challenge, submit, leaderboard
interview.py 3 Start interview, answer question, get feedback
notifications.py 3 List, mark read, dismiss
digest.py 2 Email digest subscribe, unsubscribe
newsletter.py 3 Subscribe, confirm, unsubscribe
uploads.py 3 Upload image, upload PDF, upload data file
redirects.py 3 Create, list, resolve
feed.py 1 RSS/Atom feed generation
recommendations.py 1 Personalized post recommendations
certificate.py 2 Generate series completion certificate, verify
study_notes.py 3 Create, list, delete personal study notes
settings.py 2 Get/update user settings

That's 29 routers serving approximately 120 individual endpoints. Each router is between 50 and 300 lines. The admin.py router is the largest at ~500 lines because it handles post CRUD with all the tag/series/difficulty associations.


The 21 Database Models

All models inherit from SQLAlchemy's DeclarativeBase and live in app/models/:

class Base(DeclarativeBase):
    pass
Enter fullscreen mode Exit fullscreen mode

Key models and their relationships:

Model Key Fields Relationships
User email, password_hash, xp, level, streak has_many: posts, comments, progress, bookmarks
Post title, slug, content, status, difficulty belongs_to: series; has_many: tags (M2M), comments, bookmarks
Series title, slug, description has_many: posts
Tag name, slug has_many: posts (M2M)
Comment content, is_approved belongs_to: user, post; self-referential: parent
Bookmark belongs_to: user, post (unique constraint)
UserProgress completed_at, read_time_spent belongs_to: user, post
Badge name, description, icon, criteria has_many: users (M2M via UserBadge)
Challenge day, question, answer, difficulty has_many: submissions
Payment stripe_session_id, status, amount belongs_to: user
Subscription stripe_subscription_id, status, plan belongs_to: user
RagChunk content, embedding (vector), metadata belongs_to: post
SrsCard ease_factor, interval, review_count, next_review belongs_to: user, post
Notification type, title, message, is_read belongs_to: user
Redirect old_slug, new_slug
Referral code, reward_xp belongs_to: user; has_many: referred users
PostView viewed_at, ip_address belongs_to: post (analytics)
PostReaction reaction_type belongs_to: user, post
QuizAttempt score, total_questions, answers belongs_to: user
InterviewSession questions, answers, overall_score belongs_to: user

The most interesting table is RagChunk. It stores post content split into chunks, each with a 1536-dimensional vector embedding. The search query is: find chunks whose embedding is closest to the query embedding, filtered by the user's premium tier. This is the core of the "Ask AI" feature.


Redis as the Glue Layer

Redis isn't a cache in this architecture — it's a service bus. It handles five distinct concerns:

1. Rate limiting — slowapi uses Redis as its backing store. Each endpoint family has its own limit (100/hr for anonymous, 500/hr for authenticated). The key is rate_limit:{ip}:{route_group} with a sliding window counter.

2. OAuth state — Google OAuth uses a redirect-based flow. The state parameter (a random token) is stored in Redis with a 10-minute TTL. After the callback, the token is verified and deleted. This prevents CSRF on the OAuth handshake.

3. Embedding cache — When a user asks the RAG system a question, the query is first checked against a Redis set of recent queries. If found within 5 minutes, the cached embedding is reused. This saves ~200ms per query on repeated questions.

4. Task queue — Redis pub/sub dispatches background tasks: email digests, content revalidation (purging Cloudflare cache), and maintenance jobs. The publisher pushes to a channel, and the APScheduler subscriber picks it up.

5. Leaderboard — XP rankings use Redis sorted sets (ZADD, ZREVRANK, ZRANGE). The leaderboard is recomputed every 5 seconds from a materialized PostgreSQL view, then stored in Redis for fast reads. This avoids sorting 10,000+ users on every page load.


Background Scheduler

app/core/scheduler.py runs four recurring jobs:

def start_scheduler():
    scheduler = AsyncIOScheduler()
    scheduler.add_job(send_daily_digests, "cron", hour=8, minute=0)
    scheduler.add_job(regenerate_sitemap, "cron", hour=2, minute=0)
    scheduler.add_job(clean_expired_tokens, "cron", hour=3, minute=0)
    scheduler.add_job(check_stripe_subscriptions, "interval", hours=1)
    scheduler.start()
Enter fullscreen mode Exit fullscreen mode
  • Daily digests — queries the Post table for posts published in the last 24 hours, assembles an HTML email, and sends via SMTP to subscribed users.
  • Sitemap regeneration — queries all published posts and series, generates a fresh sitemap.xml, and pings Google/Bing.
  • Token cleanup — deletes expired refresh tokens from the database.
  • Stripe sync — checks for subscriptions that should have expired and marks them accordingly.

The scheduler runs inside the same Python process as the FastAPI app. No separate Celery worker needed.


Frontend — blog_frontend/

blog_frontend/
├── src/
│   ├── app/                  # Next.js App Router pages
│   │   ├── blog/             # Blog posts (dynamic routes)
│   │   ├── admin/            # Admin dashboard (protected)
│   │   ├── login/            # Auth pages
│   │   ├── register/
│   │   ├── profile/          # User profiles, settings
│   │   ├── series/           # Series index + detail
│   │   ├── tags/             # Tag index + filtered posts
│   │   ├── search/           # Full-text + vector search UI
│   │   ├── ask/              # RAG chat interface
│   │   ├── challenge/        # Daily coding challenge
│   │   ├── leaderboard/      # XP rankings
│   │   ├── milestones/       # Badges, progress, knowledge graph
│   │   ├── bookmarks/        # Saved posts
│   │   ├── layout.tsx        # Root layout with metadata defaults
│   │   ├── robots.ts         # Dynamic robots.txt
│   │   └── sitemap.ts        # Dynamic sitemap.xml
│   ├── components/           # Reusable React components
│   │   ├── admin/            # PostEditor, AnalyticsChart, etc.
│   │   ├── blog/             # MarkdownRenderer, PostCard, etc.
│   │   ├── layout/           # Navbar, Footer, CommandPalette
│   │   ├── ui/               # Button, Input, Spinner, GlassCard
│   │   ├── user/             # BadgeGrid, UserStats, KnowledgeGraph
│   │   ├── premium/          # PremiumGate, PlanSelectionModal
│   │   └── rag/              # RagChat overlay
│   ├── contexts/             # AuthContext, ThemeContext, LearningContext
│   ├── lib/                  # API client, utilities, types
│   └── workers/              # Web Workers (Python runner)
├── public/                   # Static assets
├── e2e/                      # Playwright tests
├── next.config.ts
├── tailwind.config.ts
├── vitest.config.ts
└── postcss.config.js
Enter fullscreen mode Exit fullscreen mode

The API Client — src/lib/api.ts

The frontend communicates with the backend through a typed API client. Every endpoint is a function:

export const postsApi = {
  get: (slug: string, token?: string) => 
    apiFetch<PostResponse>(`/posts/${slug}`, { auth: true, token }),
  list: (params?: PostListParams) => 
    apiFetch<PaginatedResponse<PostListItem>>(`/posts?${toQuery(params)}`),
  create: (payload: CreatePostPayload) =>
    apiFetch<PostResponse>("/posts", { method: "POST", body: payload, auth: true }),
  // ... 5 more methods
};
Enter fullscreen mode Exit fullscreen mode

The apiFetch wrapper handles:

  • Automatic JWT token injection from cookies or localStorage
  • 401 → token refresh → retry (with debounce to avoid race conditions)
  • Error normalization (FastAPI validation errors → user-friendly messages)
  • Request deduplication for concurrent identical calls

Server Components vs. Client Components

Every page is a server component by default. Client components are only used where interactivity is required:

  • PostEditor — markdown editing, tag selection, image upload
  • RagChat — streaming chat UI with source citations
  • KnowledgeGraph — D3.js force-directed graph
  • Navbar/CommandPalette — user menu, keyboard shortcuts
  • ThemeToggle — dark/light mode

Server components handle everything else: data fetching, metadata generation, static params, and most of the rendering. This means the average page ships ~40KB of HTML instead of ~200KB of JavaScript.


Shared Infrastructure — nginx/

server {
    listen 80;
    server_name madhudadi.in;

    location /blog {
        proxy_pass http://frontend:3000;
        proxy_set_header Host $host;
    }

    location /api/ {
        proxy_pass http://backend:8000;
        proxy_set_header Host $host;
    }
}
Enter fullscreen mode Exit fullscreen mode

Nginx handles:

  • Routing/blog → Next.js frontend, /api → FastAPI backend
  • Caching — static assets (CSS, JS, images) cached for 1 year with hashed filenames
  • Compression — brotli for modern browsers, gzip fallback
  • Security headers — HSTS, X-Content-Type-Options, CSP, Referrer-Policy
  • Cloudflare integration — real IP headers, cache purging via API

The key insight: Nginx is the only entry point. There's no Kubernetes ingress, no cloud load balancer, no API gateway. One Nginx config handles everything.


What I'd Change

Looking back, there are three things I'd do differently:

1. Use a task queue from day one. The Redis pub/sub approach works, but it's not durable. If the app crashes mid-job, the task is lost. A proper queue (ARQ, Celery with Redis broker) would give retries, dead-letter queues, and job persistence.

2. Split the admin router. app/routers/admin.py at 500+ lines handles too many concerns. Post CRUD, user management, analytics queries, and task management should be separate routers. The file grew organically and never got refactored.

3. Add OpenAPI types to the frontend. The API client in src/lib/api.ts is manually typed. There's no code generation from the FastAPI OpenAPI schema. This means when the backend adds a field, the frontend type needs a manual update. Using openapi-typescript or orval would eliminate this class of bugs.


What's Next

In the next post, I'll dive into the RAG chat system — how embeddings are generated, how hybrid search works, and how the streaming response pipeline is built.


Built with FastAPI, Next.js 16, PostgreSQL, Redis, and zero third-party CMS. Deployed on a $12/month VPS.

By Madhu Dadi

Top comments (0)