Specifications
RankFlow AI — Infrastructure & DevOps Documentation
AWS Mumbai (ap-south-1)
docs/specs/infrastructure-devops.mdOn this page
- 1. AWS Architecture
- EC2 Instance
- 2. Dokploy Services
- 3. Docker Compose
- Dockerfile (App)
- Dockerfile (Worker)
- 4. Environment Variables
- 5. CI/CD Pipeline
- GitHub Actions
- Deployment Flow
- 6. Cloudflare Configuration
- DNS Records
- Page Rules
- SSL/TLS
- 7. Storage (R2)
- Bucket Structure
- R2 Policy
- 8. ISR & Caching Strategy
- ISR Revalidation Strategy
- Caching Layers
- 9. Database Migration
- LandingPage → DirectoryProfile
- Migration Script
- Post-Migration Steps
- 10. Monitoring & Alerts
- Health Checks
- Alert Rules
- Logging
- 11. Backup Strategy
- Database Backup
- Redis Backup
- Disaster Recovery
- 12. Cost Projections
- Infrastructure (Monthly)
- API Costs (Per 100 Clients)
- Total Monthly Cost @ 100 Clients
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#
- Regenerate all profile URLs from
subdomaintocitySlug/slugformat - Update GBP
websiteUrifor all clients to point to directory profile URL - Update citation links from old subdomain to new directory URL
- Regenerate XML sitemaps with new directory URL structure
- Trigger ISR revalidation for all published profiles
- 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