Browse documentation

Specifications

RankFlow AI — Infrastructure & DevOps Documentation

AWS Mumbai (ap-south-1)

docs/specs/infrastructure-devops.md
On this page

Version: 1.1.0
Platform: Dokploy on AWS EC2
Region: ap-south-1 (Mumbai)
Container: Docker Compose
Status: Post-Pivot (Directory Model)


1. AWS Architecture#

AWS Mumbai (ap-south-1)
│
├── VPC
│   ├── Public Subnet
│   │   └── EC2 t3.xlarge (4 vCPU, 16GB RAM)
│   │       └── Dokploy (Docker Compose)
│   │           ├── nextjs-app (Next.js frontend + API)
│   │           ├── inngest-server (workflow engine)
│   │           ├── bullmq-worker (background jobs)
│   │           ├── postgres (database)
│   │           └── redis (cache + queue)
│   └── Security Groups
│
├── Cloudflare R2
│   ├── /profiles/ (doctor photos, clinic images, logos, OG images)
│   ├── /pdfs/ (monthly reports)
│   └── /backups/ (DB backups)
│
├── Cloudflare
│   ├── DNS: rankflow.ai, directory-domain.com (e.g., indiandoctors.in)
│   ├── CDN + caching (directory pages)
│   └── SSL (single domain certificate)
│
└── IAM: dokploy-ec2-role (R2 access via S3-compatible API)

EC2 Instance#

Spec Value
Instance t3.xlarge
vCPU 4
RAM 16 GB
Storage 100 GB GP3 SSD
OS Ubuntu 22.04 LTS
Cost ~$130/month

2. Dokploy Services#

Service Type Resource Purpose
rankflow-app App (Docker) 2 vCPU, 4GB Next.js (frontend + API routes)
rankflow-worker App (Docker) 1 vCPU, 2GB BullMQ workers
inngest-server App (Docker) 1 vCPU, 2GB Inngest workflow engine
postgres Database 1 vCPU, 2GB PostgreSQL (Dokploy managed)
redis Database 0.5 vCPU, 1GB Redis (cache + queue)

3. Docker Compose#

# docker-compose.yml
version: "3.8"

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - REDIS_URL=${REDIS_URL}
      - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
      - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
      - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
      - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - HYPERBROWSER_API_KEY=${HYPERBROWSER_API_KEY}
      - FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY}
      - DATAFORSEO_LOGIN=${DATAFORSEO_LOGIN}
      - DATAFORSEO_PASSWORD=${DATAFORSEO_PASSWORD}
      - SERPAPI_KEY=${SERPAPI_KEY}
      - COMPOSIO_API_KEY=${COMPOSIO_API_KEY}
      - ZERNIO_API_KEY=${ZERNIO_API_KEY}
      - RESEND_API_KEY=${RESEND_API_KEY}
      - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
      - RAZORPAY_KEY_SECRET=${RAZORPAY_KEY_SECRET}
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
      - R2_BUCKET=${R2_BUCKET}
      - R2_ENDPOINT=${R2_ENDPOINT}
      - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
      - CLOUDFLARE_ZONE_ID=${CLOUDFLARE_ZONE_ID}
      - DIRECTORY_URL=${DIRECTORY_URL}
    depends_on:
      - postgres
      - redis
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  worker:
    build:
      context: .
      dockerfile: Dockerfile.worker
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - REDIS_URL=${REDIS_URL}
      - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
      - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - HYPERBROWSER_API_KEY=${HYPERBROWSER_API_KEY}
      - FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY}
      - COMPOSIO_API_KEY=${COMPOSIO_API_KEY}
      - ZERNIO_API_KEY=${ZERNIO_API_KEY}
      - RESEND_API_KEY=${RESEND_API_KEY}
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
      - R2_BUCKET=${R2_BUCKET}
      - R2_ENDPOINT=${R2_ENDPOINT}
      - DIRECTORY_URL=${DIRECTORY_URL}
    depends_on:
      - redis
    restart: unless-stopped
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: '1'
          memory: 2G

  inngest:
    image: inngest/inngest:latest
    ports:
      - "8288:8288"
    environment:
      - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
      - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
      - REDIS_URI=${REDIS_URL}
    command: ["inngest", "dev", "-u", "http://app:3000/api/inngest"]
    restart: unless-stopped
    depends_on:
      - app

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=rankflow
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=rankflow
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./backups:/backups
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U rankflow"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5

volumes:
  postgres-data:
  redis-data:

Dockerfile (App)#

# Dockerfile
FROM node:20-alpine AS base

# Install dependencies
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* pnpm-lock.yaml* ./
RUN npm ci

# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build

# Production
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

Dockerfile (Worker)#

# Dockerfile.worker
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npx prisma generate
ENV NODE_ENV=production
CMD ["node", "src/server/bullmq/worker-runner.js"]

4. Environment Variables#

# App
NODE_ENV=production
APP_URL=https://rankflow.ai
DIRECTORY_URL=https://indiandoctors.in

# Database
DATABASE_URL=postgresql://rankflow:xxx@postgres:5432/rankflow

# Redis
REDIS_URL=redis://redis:6379

# Auth (Better Auth)
BETTER_AUTH_SECRET=xxx
BETTER_AUTH_URL=https://rankflow.ai

# Google OAuth + GBP
GOOGLE_CLIENT_ID=xxx
GOOGLE_CLIENT_SECRET=xxx

# AI
ANTHROPIC_API_KEY=xxx
OPENAI_API_KEY=xxx

# SEO Data
DATAFORSEO_LOGIN=xxx
DATAFORSEO_PASSWORD=xxx
SERPAPI_KEY=xxx

# Browser Automation
HYPERBROWSER_API_KEY=xxx
FIRECRAWL_API_KEY=xxx

# Social
COMPOSIO_API_KEY=xxx
ZERNIO_API_KEY=xxx

# Storage (Cloudflare R2)
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
R2_BUCKET=rankflow-assets
R2_ENDPOINT=https://xxx.r2.cloudflarestorage.com

# Inngest
INNGEST_SIGNING_KEY=xxx
INNGEST_EVENT_KEY=xxx

# Email
RESEND_API_KEY=xxx

# Billing
STRIPE_SECRET_KEY=xxx
STRIPE_WEBHOOK_SECRET=xxx
STRIPE_PUBLISHABLE_KEY=xxx
RAZORPAY_KEY_ID=xxx
RAZORPAY_KEY_SECRET=xxx

# DNS
CLOUDFLARE_API_TOKEN=xxx
CLOUDFLARE_ZONE_ID=xxx

# Security
ENCRYPTION_KEY=xxx

# Monitoring
SENTRY_DSN=xxx

5. CI/CD Pipeline#

GitHub Actions#

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx prisma generate
      - run: npm run lint
      - run: npm run typecheck
      - run: npm run test:unit

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build Docker image
        run: docker build -t rankflow-app:latest .
      - name: Build Worker image
        run: docker build -f Dockerfile.worker -t rankflow-worker:latest .

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to Dokploy
        uses: dokploy/deploy-action@v1
        with:
          api_key: ${{ secrets.DOKPLOY_API_KEY }}
          application_id: ${{ secrets.DOKPLOY_APP_ID }}

Deployment Flow#

1. Push to main branch
2. GitHub Actions: lint + typecheck + unit tests
3. Build Docker images
4. Push to container registry
5. Dokploy pulls new images
6. Rolling restart (zero downtime)
7. Health check verifies deployment
8. Notify Slack #deployments

Note: No per-client static builds. Single deployment serves all directory profiles via dynamic Next.js routes with ISR.


6. Cloudflare Configuration#

DNS Records#

Type Name Value TTL Proxied
A @ EC2 IP Auto
A directory-domain EC2 IP Auto

Note: No wildcard DNS (*.rankflow.in) and no custom domain CNAME records. The directory runs on a single domain (e.g., indiandoctors.in) with dynamic Next.js routes (/clinics/[city]/[slug]). All client profiles are served from this single domain; no per-client subdomains or custom domains are supported.

Page Rules#

URL Setting Value
indiandoctors.in/clinics/* Cache Level Cache Everything
indiandoctors.in/specialty/* Cache Level Cache Everything
indiandoctors.in/api/* Cache Level Bypass
indiandoctors.in/api/inngest Cache Level Bypass

Note: Per-subdomain caching rules are removed. All directory pages (profile, city, specialty) are cached under the single directory domain.

SSL/TLS#

Setting Value
Mode Full (strict)
Certificate Cloudflare-managed
Minimum TLS 1.2
HSTS Enabled

7. Storage (R2)#

Bucket Structure#

rankflow-assets (Cloudflare R2)
├── profiles/
│   ├── {profileId}/
│   │   ├── logo.png
│   │   ├── doctor-photo.jpg
│   │   ├── gallery/
│   │   └── og-image.jpg
│   └── ...
├── pdfs/
│   ├── reports/
│   │   ├── report-{practiceId}-{period}.pdf
│   │   └── ...
│   └── invoices/
│       ├── invoice-{invoiceId}.pdf
│       └── ...
├── citations/
│   └── {practiceId}/
│       ├── justdial.png
│       ├── practo.png
│       └── ...
└── backups/
    ├── db/
    │   ├── rankflow-2025-01-01.sql.gz
    │   └── ...
    └── redis/
        └── ...

Note: No per-client template directories or static site assets. All directory profiles use a unified layout; images are the only variable assets per profile. Profile photos and clinic images are stored in R2 and served via Cloudflare CDN.

R2 Policy#

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadForProfiles",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::rankflow-assets/profiles/*"
    },
    {
      "Sid": "PrivateReports",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::ACCOUNT_ID:role/dokploy-ec2-role"
      },
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::rankflow-assets/pdfs/*"
    }
  ]
}

8. ISR & Caching Strategy#

ISR Revalidation Strategy#

Directory profile pages use Next.js ISR on dynamic routes. No per-client SSG builds are performed.

// app/clinics/[city]/[slug]/page.tsx
export const revalidate = 3600; // 1 hour default

Revalidation triggers:

Event Action Target
Profile published revalidatePath(/clinics/${city}/${slug}) Profile page + city page
Profile updated revalidatePath(/clinics/${city}/${slug}) + revalidatePath(/clinics/${city}) Profile page + city page
Profile unpublished revalidatePath(/clinics/${city}/${slug}) Profile page (returns 404)
Monthly content refresh Batch revalidation via Inngest job All refreshed profiles
New city page created revalidatePath(/clinics/${city}) City page + sitemap

Caching Layers#

Layer Cache Target TTL Invalidation
Cloudflare CDN Static assets, images, CSS, JS 1 year Manual purge on deploy
Cloudflare CDN HTML pages (profile, city, specialty) 1 hour ISR revalidation
Next.js ISR Profile pages (/clinics/[city]/[slug]) 1 hour On content update
Next.js ISR City pages, specialty pages 24 hours On new profile publish
Redis (Upstash) API responses (profile data, search) 5 minutes On content update
Database Profile queries No cache Real-time

No per-client SSG builds. All profiles are served through a single Next.js dynamic route with ISR. The deployment pipeline builds once and serves all profiles.


9. Database Migration#

LandingPage → DirectoryProfile#

The pivot from individual landing pages to directory profiles requires a schema migration.

Old Model New Model Action
LandingPage DirectoryProfile Rename table, add fields
subdomain citySlug + slug Replace with composite URL path
customDomain Remove field
template profileLayout Rename, reduce to 1-2 layouts
siteSection profileSection Rename, adapt to bio/services/faq/reviews
sitePublished status (enum) Replace boolean with enum: DRAFT, PENDING_REVIEW, PUBLISHED, PAUSED, ARCHIVED
pageTraffic viewCount Rename, add lastViewedAt

Migration Script#

-- 1. Create new DirectoryProfile table
CREATE TABLE "DirectoryProfile" (
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
  practiceId TEXT UNIQUE NOT NULL,
  status TEXT NOT NULL DEFAULT 'DRAFT',
  citySlug TEXT NOT NULL,
  slug TEXT NOT NULL,
  citySlug_slug TEXT UNIQUE NOT NULL,
  title TEXT NOT NULL,
  metaDescription TEXT NOT NULL,
  bio TEXT NOT NULL,
  photoUrl TEXT,
  logoUrl TEXT,
  galleryUrls TEXT[],
  phone TEXT NOT NULL,
  whatsapp TEXT,
  email TEXT,
  address TEXT NOT NULL,
  hours JSONB,
  schemaMarkup JSONB,
  keywords TEXT[],
  canonicalUrl TEXT NOT NULL,
  gbpUrl TEXT,
  gbpPlaceId TEXT,
  viewCount INTEGER DEFAULT 0,
  lastViewedAt TIMESTAMP,
  createdAt TIMESTAMP DEFAULT NOW(),
  updatedAt TIMESTAMP DEFAULT NOW(),
  publishedAt TIMESTAMP
);

-- 2. Create indexes
CREATE INDEX "DirectoryProfile_citySlug_idx" ON "DirectoryProfile"(citySlug);
CREATE INDEX "DirectoryProfile_status_idx" ON "DirectoryProfile"(status);
CREATE INDEX "DirectoryProfile_publishedAt_idx" ON "DirectoryProfile"(publishedAt);

-- 3. Migrate existing landing pages
INSERT INTO "DirectoryProfile" (
  practiceId, status, citySlug, slug, citySlug_slug,
  title, metaDescription, bio, phone, email, address,
  hours, schemaMarkup, keywords, canonicalUrl, gbpUrl,
  viewCount, createdAt, updatedAt, publishedAt
)
SELECT 
  practiceId,
  CASE WHEN published THEN 'PUBLISHED' ELSE 'DRAFT' END,
  'kochi', -- default city for existing records
  slug,
  CONCAT('kochi/', slug),
  title, metaDescription, content, phone, email, address,
  hours, schemaMarkup, keywords, canonicalUrl, gbpUrl,
  viewCount, createdAt, updatedAt, publishedAt
FROM "LandingPage";

-- 4. Drop old LandingPage table (after verification)
-- DROP TABLE "LandingPage";
-- DROP TABLE "SiteSection";
-- DROP TABLE "DomainRecord";

Post-Migration Steps#

  1. Regenerate all profile URLs from subdomain to citySlug/slug format
  2. Update GBP websiteUri for all clients to point to directory profile URL
  3. Update citation links from old subdomain to new directory URL
  4. Regenerate XML sitemaps with new directory URL structure
  5. Trigger ISR revalidation for all published profiles
  6. Update client-facing copy from "Your landing page" to "Your directory profile"

10. Monitoring & Alerts#

Health Checks#

Endpoint Check Interval
/api/health DB, Redis, Inngest Every 30s
/api/health/jobs Queue depths Every 60s

Alert Rules#

Condition Severity Action
Health check fails Critical PagerDuty + Slack
Queue depth > 100 Warning Slack #alerts
Job failure rate > 5% Warning Slack #alerts
API cost > $50/day Warning Email admin
Disk usage > 80% Critical Auto-scale + alert
Memory usage > 90% Critical Restart + alert

Logging#

// src/lib/logger.ts
import pino from "pino";

export const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  base: { service: "rankflow" },
  redact: [
    "req.headers.authorization",
    "req.headers.cookie",
    "password",
    "accessToken",
    "refreshToken",
    "token",
  ],
  transport: process.env.NODE_ENV === "development"
    ? { target: "pino-pretty", options: { colorize: true } }
    : undefined,
});

11. Backup Strategy#

Database Backup#

# Daily automated backup (cron on EC2)
0 2 * * * pg_dump -U rankflow rankflow | gzip > /backups/db/rankflow-$(date +%Y-%m-%d).sql.gz

# Upload to R2
aws s3 cp /backups/db/rankflow-$(date +%Y-%m-%d).sql.gz s3://rankflow-assets/backups/db/ --endpoint-url=$R2_ENDPOINT

# Retain 30 days, delete older
find /backups/db -name "*.sql.gz" -mtime +30 -delete

Redis Backup#

# Daily RDB snapshot
0 3 * * * redis-cli BGSAVE
# Copy dump.rdb to R2
aws s3 cp /data/dump.rdb s3://rankflow-assets/backups/redis/ --endpoint-url=$R2_ENDPOINT

Disaster Recovery#

Scenario RTO RPO Recovery Steps
EC2 failure 30 min 24h Launch new EC2, restore from latest backup
DB corruption 1 hour 24h Restore from R2 backup, replay WAL if possible
Accidental deletion 15 min 0 Point-in-time recovery from backups
Region outage 4 hours 24h Spin up in backup region (multi-region R2)

12. Cost Projections#

Infrastructure (Monthly)#

Service Tier Cost (USD)
EC2 t3.xlarge (Dokploy) 4 vCPU, 16GB $130
Cloudflare R2 100GB $5
Cloudflare Pro DNS + CDN $20
Total Infrastructure ~$155/mo

API Costs (Per 100 Clients)#

Service Usage Cost (USD)
Claude Sonnet 30k requests/mo $150
GPT-4o 10k requests/mo $50
DataForSEO 50k API calls/mo $100
SerpAPI 10k calls/mo $50
Hyperbrowser 500 sessions/mo $50
Firecrawl 2k scrapes/mo $10
Composio 100 connections $30
Zernio 500 schedules/mo $30
Resend 50k emails/mo $0 (free tier)
Total API ~$470/mo

Total Monthly Cost @ 100 Clients#

Category Cost
Infrastructure $155
APIs $470
Total ~$625/mo
Per client ~$6.25/mo

At Rs 8,000 ARPU ($95), gross margin = ~93%.

Savings from pivot:

  • Old model: ~$200–300/mo (500 subdomains + wildcard DNS + custom domain support + per-client SSG builds)
  • New model: ~$50–100/mo (single domain + ISR caching + no custom domains)
  • Savings at 500 clients: ~$150–200/mo

End of Infrastructure & DevOps Documentation