Wiki source code of Zero-Cost Hosting Implementation Guide
Last modified by Robert Schaub on 2026/02/08 08:30
Hide last authors
| author | version | line-number | content |
|---|---|---|---|
| |
1.1 | 1 | = Zero-Cost Hosting Implementation Guide = |
| 2 | |||
| 3 | **Document Version:** 1.0\\ | ||
| 4 | **Date:** 2026-01-02\\ | ||
| 5 | **Status:** Approved - Ready for Implementation\\ | ||
| 6 | **Target:** Pre-release beta with logged-in users, $0-5/month budget | ||
| 7 | |||
| 8 | ---- | ||
| 9 | |||
| 10 | == Executive Summary == | ||
| 11 | |||
| 12 | This guide provides a complete implementation plan for hosting FactHarbor's pre-release beta version with **near-zero infrastructure costs** ($0-5/month) while supporting 10-50 active beta users. | ||
| 13 | |||
| 14 | **Key Strategy:**\\ | ||
| 15 | * Leverage generous free tiers from modern cloud providers | ||
| 16 | * Implement aggressive cost controls (rate limiting, caching, tiered LLM models) | ||
| 17 | * Use separated architecture to reduce AI costs by 70% | ||
| 18 | * Scale infrastructure costs only when revenue/funding is secured | ||
| 19 | |||
| 20 | ---- | ||
| 21 | |||
| 22 | == Recommended Architecture: Fly.io Stack == | ||
| 23 | |||
| 24 | === Why Fly.io? === | ||
| 25 | |||
| 26 | * **True $0/month possible** with generous free tier | ||
| 27 | * **Works with any tech stack** (Docker-based) | ||
| 28 | * **Global deployment** in seconds | ||
| 29 | * **Includes PostgreSQL + Redis** in free tier | ||
| 30 | * **Auto-suspend when idle** (saves compute) | ||
| 31 | * **Easy migration path** to paid tier when ready | ||
| 32 | |||
| 33 | ---- | ||
| 34 | |||
| 35 | == Complete Stack Overview == | ||
| 36 | |||
| 37 | {{code}} | ||
| 38 | ┌──────────────────────────────────────────────────────────┐ | ||
| 39 | │ USER BROWSER │ | ||
| 40 | └────────────────────┬─────────────────────────────────────┘ | ||
| 41 | │ | ||
| 42 | │ HTTPS | ||
| 43 | ▼ | ||
| 44 | ┌──────────────────────────────────────────────────────────┐ | ||
| 45 | │ Cloudflare Pages (Frontend) │ | ||
| 46 | │ • React/Vue/Svelte SPA │ | ||
| 47 | │ • FREE: Unlimited bandwidth │ | ||
| 48 | │ • Global CDN │ | ||
| 49 | └────────────────────┬─────────────────────────────────────┘ | ||
| 50 | │ | ||
| 51 | │ REST API | ||
| 52 | ▼ | ||
| 53 | ┌──────────────────────────────────────────────────────────┐ | ||
| 54 | │ Fly.io App (Backend API) │ | ||
| 55 | │ • Node.js/Python/Go/.NET │ | ||
| 56 | │ • FREE: 3 shared-cpu VMs (256MB each) │ | ||
| 57 | │ • Auto-suspend when idle │ | ||
| 58 | └──────┬──────────┬──────────┬────────────────────────────┘ | ||
| 59 | │ │ │ | ||
| 60 | ▼ ▼ ▼ | ||
| 61 | ┌─────────┐ ┌──────────┐ ┌─────────────────┐ | ||
| 62 | │ Fly.io │ │ Upstash │ │ Anthropic │ | ||
| 63 | │Postgres │ │ Redis │ │ Claude API │ | ||
| 64 | │ │ │ │ │ │ | ||
| 65 | │ FREE: │ │ FREE: │ │ PAY-PER-USE: │ | ||
| 66 | │ 3GB │ │ 10k │ │ ~$2-5/mo with │ | ||
| 67 | │ storage │ │ cmds/day │ │ optimizations │ | ||
| 68 | └─────────┘ └──────────┘ └─────────────────┘ | ||
| 69 | {{/code}} | ||
| 70 | |||
| 71 | **Total Monthly Cost: $0-5** | ||
| 72 | |||
| 73 | ---- | ||
| 74 | |||
| 75 | == Implementation Steps == | ||
| 76 | |||
| 77 | === Step 1: Set Up Fly.io Account and Infrastructure === | ||
| 78 | |||
| 79 | ==== 1.1 Create Fly.io Account ==== | ||
| 80 | |||
| 81 | {{code language="bash"}} | ||
| 82 | # Install flyctl CLI | ||
| 83 | # Windows (PowerShell) | ||
| 84 | iwr https://fly.io/install.ps1 -useb | iex | ||
| 85 | |||
| 86 | # macOS/Linux | ||
| 87 | curl -L https://fly.io/install.sh | sh | ||
| 88 | |||
| 89 | # Sign up (credit card required but NOT charged for free tier) | ||
| 90 | fly auth signup | ||
| 91 | |||
| 92 | # Or log in if you have an account | ||
| 93 | fly auth login | ||
| 94 | {{/code}} | ||
| 95 | |||
| 96 | ==== 1.2 Create PostgreSQL Database ==== | ||
| 97 | |||
| 98 | {{code language="bash"}} | ||
| 99 | # Create a new Postgres cluster (uses free tier) | ||
| 100 | fly postgres create \ | ||
| 101 | --name factharbor-db \ | ||
| 102 | --region ord \ | ||
| 103 | --vm-size shared-cpu-1x \ | ||
| 104 | --volume-size 3 \ | ||
| 105 | --initial-cluster-size 1 | ||
| 106 | |||
| 107 | # Save the connection string displayed (you'll need it) | ||
| 108 | # Format: postgres://user:password@factharbor-db.internal:5432/dbname | ||
| 109 | {{/code}} | ||
| 110 | |||
| 111 | ==== 1.3 Create Redis Cache (Upstash) ==== | ||
| 112 | |||
| 113 | {{code language="bash"}} | ||
| 114 | # Sign up at https://upstash.com (separate service, better free tier) | ||
| 115 | # Free tier: 10,000 commands/day, 256MB storage | ||
| 116 | |||
| 117 | # Create database via Upstash console | ||
| 118 | # Select: Global, REST API enabled | ||
| 119 | # Save connection details: | ||
| 120 | # - UPSTASH_REDIS_REST_URL | ||
| 121 | # - UPSTASH_REDIS_REST_TOKEN | ||
| 122 | {{/code}} | ||
| 123 | |||
| 124 | ---- | ||
| 125 | |||
| 126 | === Step 2: Containerize Your Application === | ||
| 127 | |||
| 128 | ==== 2.1 Create Dockerfile (Node.js Example) ==== | ||
| 129 | |||
| 130 | {{code language="dockerfile"}} | ||
| 131 | # Dockerfile | ||
| 132 | FROM node:20-alpine AS builder | ||
| 133 | |||
| 134 | WORKDIR /app | ||
| 135 | |||
| 136 | # Copy package files | ||
| 137 | COPY package*.json ./ | ||
| 138 | |||
| 139 | # Install dependencies | ||
| 140 | RUN npm ci --only=production | ||
| 141 | |||
| 142 | # Copy source code | ||
| 143 | COPY . . | ||
| 144 | |||
| 145 | # Build if needed (for TypeScript, etc.) | ||
| 146 | RUN npm run build | ||
| 147 | |||
| 148 | # Production image | ||
| 149 | FROM node:20-alpine | ||
| 150 | |||
| 151 | WORKDIR /app | ||
| 152 | |||
| 153 | # Copy built app | ||
| 154 | COPY --from=builder /app/dist ./dist | ||
| 155 | COPY --from=builder /app/node_modules ./node_modules | ||
| 156 | COPY --from=builder /app/package*.json ./ | ||
| 157 | |||
| 158 | # Expose port | ||
| 159 | EXPOSE 8080 | ||
| 160 | |||
| 161 | # Start app | ||
| 162 | CMD ["node", "dist/server.js"] | ||
| 163 | {{/code}} | ||
| 164 | |||
| 165 | ==== 2.2 Create Dockerfile (.NET Example) ==== | ||
| 166 | |||
| 167 | {{code language="dockerfile"}} | ||
| 168 | # Dockerfile | ||
| 169 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build | ||
| 170 | WORKDIR /src | ||
| 171 | |||
| 172 | # Copy csproj and restore dependencies | ||
| 173 | COPY ["FactHarbor.API/FactHarbor.API.csproj", "FactHarbor.API/"] | ||
| 174 | RUN dotnet restore "FactHarbor.API/FactHarbor.API.csproj" | ||
| 175 | |||
| 176 | # Copy everything else and build | ||
| 177 | COPY . . | ||
| 178 | WORKDIR "/src/FactHarbor.API" | ||
| 179 | RUN dotnet build "FactHarbor.API.csproj" -c Release -o /app/build | ||
| 180 | |||
| 181 | # Publish | ||
| 182 | FROM build AS publish | ||
| 183 | RUN dotnet publish "FactHarbor.API.csproj" -c Release -o /app/publish | ||
| 184 | |||
| 185 | # Runtime image | ||
| 186 | FROM mcr.microsoft.com/dotnet/aspnet:8.0 | ||
| 187 | WORKDIR /app | ||
| 188 | COPY --from=publish /app/publish . | ||
| 189 | |||
| 190 | EXPOSE 8080 | ||
| 191 | ENTRYPOINT ["dotnet", "FactHarbor.API.dll"] | ||
| 192 | {{/code}} | ||
| 193 | |||
| 194 | ==== 2.3 Test Locally ==== | ||
| 195 | |||
| 196 | {{code language="bash"}} | ||
| 197 | # Build image | ||
| 198 | docker build -t factharbor-api . | ||
| 199 | |||
| 200 | # Run locally | ||
| 201 | docker run -p 8080:8080 \ | ||
| 202 | -e DATABASE_URL="your-connection-string" \ | ||
| 203 | -e REDIS_URL="your-redis-url" \ | ||
| 204 | -e ANTHROPIC_API_KEY="your-api-key" \ | ||
| 205 | factharbor-api | ||
| 206 | |||
| 207 | # Test | ||
| 208 | curl http://localhost:8080/health | ||
| 209 | {{/code}} | ||
| 210 | |||
| 211 | ---- | ||
| 212 | |||
| 213 | === Step 3: Deploy to Fly.io === | ||
| 214 | |||
| 215 | ==== 3.1 Initialize Fly App ==== | ||
| 216 | |||
| 217 | {{code language="bash"}} | ||
| 218 | # Create fly.toml config | ||
| 219 | fly launch \ | ||
| 220 | --name factharbor-api \ | ||
| 221 | --region ord \ | ||
| 222 | --no-deploy | ||
| 223 | |||
| 224 | # This creates fly.toml - edit it: | ||
| 225 | {{/code}} | ||
| 226 | |||
| 227 | ==== 3.2 Configure fly.toml ==== | ||
| 228 | |||
| 229 | {{code language="toml"}} | ||
| 230 | # fly.toml | ||
| 231 | app = "factharbor-api" | ||
| 232 | primary_region = "ord" | ||
| 233 | |||
| 234 | [build] | ||
| 235 | |||
| 236 | [env] | ||
| 237 | PORT = "8080" | ||
| 238 | NODE_ENV = "production" | ||
| 239 | |||
| 240 | [http_service] | ||
| 241 | internal_port = 8080 | ||
| 242 | force_https = true | ||
| 243 | auto_stop_machines = true # Auto-suspend when idle (saves $) | ||
| 244 | auto_start_machines = true | ||
| 245 | min_machines_running = 0 # Can scale to 0 when idle | ||
| 246 | |||
| 247 | [[http_service.checks]] | ||
| 248 | interval = "10s" | ||
| 249 | timeout = "2s" | ||
| 250 | grace_period = "5s" | ||
| 251 | method = "GET" | ||
| 252 | path = "/health" | ||
| 253 | |||
| 254 | [[vm]] | ||
| 255 | memory = '256mb' | ||
| 256 | cpu_kind = 'shared' | ||
| 257 | cpus = 1 | ||
| 258 | |||
| 259 | [[statics]] | ||
| 260 | guest_path = "/app/public" | ||
| 261 | url_prefix = "/static" | ||
| 262 | {{/code}} | ||
| 263 | |||
| 264 | ==== 3.3 Set Secrets ==== | ||
| 265 | |||
| 266 | {{code language="bash"}} | ||
| 267 | # Set environment variables (encrypted) | ||
| 268 | fly secrets set \ | ||
| 269 | DATABASE_URL="postgres://user:pass@factharbor-db.internal:5432/db" \ | ||
| 270 | REDIS_URL="your-upstash-redis-url" \ | ||
| 271 | REDIS_TOKEN="your-upstash-token" \ | ||
| 272 | ANTHROPIC_API_KEY="your-claude-api-key" \ | ||
| 273 | JWT_SECRET="$(openssl rand -base64 32)" | ||
| 274 | {{/code}} | ||
| 275 | |||
| 276 | ==== 3.4 Deploy ==== | ||
| 277 | |||
| 278 | {{code language="bash"}} | ||
| 279 | # Deploy to Fly.io | ||
| 280 | fly deploy | ||
| 281 | |||
| 282 | # Check status | ||
| 283 | fly status | ||
| 284 | |||
| 285 | # View logs | ||
| 286 | fly logs | ||
| 287 | |||
| 288 | # Open in browser | ||
| 289 | fly open | ||
| 290 | {{/code}} | ||
| 291 | |||
| 292 | ---- | ||
| 293 | |||
| 294 | === Step 4: Set Up Frontend on Cloudflare Pages === | ||
| 295 | |||
| 296 | ==== 4.1 Build Frontend ==== | ||
| 297 | |||
| 298 | {{code language="bash"}} | ||
| 299 | # Example with React | ||
| 300 | cd frontend | ||
| 301 | |||
| 302 | # Build for production | ||
| 303 | npm run build | ||
| 304 | # Output: dist/ or build/ folder | ||
| 305 | {{/code}} | ||
| 306 | |||
| 307 | ==== 4.2 Deploy to Cloudflare Pages ==== | ||
| 308 | |||
| 309 | {{code language="bash"}} | ||
| 310 | # Install Wrangler CLI | ||
| 311 | npm install -g wrangler | ||
| 312 | |||
| 313 | # Login to Cloudflare | ||
| 314 | wrangler login | ||
| 315 | |||
| 316 | # Deploy | ||
| 317 | wrangler pages deploy dist \ | ||
| 318 | --project-name factharbor \ | ||
| 319 | --branch main | ||
| 320 | |||
| 321 | # Configure environment variables in Cloudflare dashboard: | ||
| 322 | # - VITE_API_URL=https://factharbor-api.fly.dev | ||
| 323 | {{/code}} | ||
| 324 | |||
| 325 | **Alternative: Use Cloudflare Pages Git Integration** | ||
| 326 | |||
| 327 | # Push frontend to GitHub | ||
| 328 | # Go to Cloudflare Dashboard → Pages → Create Project | ||
| 329 | # Connect GitHub repo | ||
| 330 | # Configure build: | ||
| 331 | #* Framework preset: React/Vue/Svelte | ||
| 332 | #* Build command: {{code}}npm run build{{/code}} | ||
| 333 | #* Build output: {{code}}dist{{/code}} | ||
| 334 | # Add environment variable: {{code}}VITE_API_URL{{/code}} | ||
| 335 | # Deploy automatically on every git push | ||
| 336 | |||
| 337 | ---- | ||
| 338 | |||
| 339 | === Step 5: Implement Cost Control Measures === | ||
| 340 | |||
| 341 | ==== 5.1 Rate Limiting (Critical!) ==== | ||
| 342 | |||
| 343 | {{code language="typescript"}} | ||
| 344 | // rate-limiter.ts | ||
| 345 | import { RateLimiterRedis } from 'rate-limiter-flexible'; | ||
| 346 | import Redis from 'ioredis'; | ||
| 347 | |||
| 348 | const redis = new Redis(process.env.REDIS_URL); | ||
| 349 | |||
| 350 | // Per-user limits | ||
| 351 | export const userRateLimiter = new RateLimiterRedis({ | ||
| 352 | storeClient: redis, | ||
| 353 | keyPrefix: 'rl:user', | ||
| 354 | points: 10, // 10 analyses | ||
| 355 | duration: 86400, // per day | ||
| 356 | blockDuration: 86400 // block for 1 day if exceeded | ||
| 357 | }); | ||
| 358 | |||
| 359 | // Global limits (safety net) | ||
| 360 | export const globalRateLimiter = new RateLimiterRedis({ | ||
| 361 | storeClient: redis, | ||
| 362 | keyPrefix: 'rl:global', | ||
| 363 | points: 100, // 100 total analyses | ||
| 364 | duration: 86400 // per day | ||
| 365 | }); | ||
| 366 | |||
| 367 | // Middleware | ||
| 368 | export async function rateLimitMiddleware(req, res, next) { | ||
| 369 | try { | ||
| 370 | const userId = req.user?.id || req.ip; | ||
| 371 | |||
| 372 | // Check user limit | ||
| 373 | await userRateLimiter.consume(userId); | ||
| 374 | |||
| 375 | // Check global limit | ||
| 376 | await globalRateLimiter.consume('global'); | ||
| 377 | |||
| 378 | next(); | ||
| 379 | } catch (err) { | ||
| 380 | res.status(429).json({ | ||
| 381 | error: 'Rate limit exceeded', | ||
| 382 | message: 'You have reached your daily analysis limit. Please try again tomorrow.' | ||
| 383 | }); | ||
| 384 | } | ||
| 385 | } | ||
| 386 | {{/code}} | ||
| 387 | |||
| 388 | ==== 5.2 Budget Alerts (Anthropic API) ==== | ||
| 389 | |||
| 390 | {{code language="typescript"}} | ||
| 391 | // budget-monitor.ts | ||
| 392 | const DAILY_BUDGET = 0.50; // $0.50/day = ~$15/month | ||
| 393 | const MONTHLY_BUDGET = 10.00; | ||
| 394 | |||
| 395 | let dailySpend = 0; | ||
| 396 | let monthlySpend = 0; | ||
| 397 | |||
| 398 | export function trackAIUsage(tokensUsed: number, model: string) { | ||
| 399 | const cost = calculateCost(tokensUsed, model); | ||
| 400 | |||
| 401 | dailySpend += cost; | ||
| 402 | monthlySpend += cost; | ||
| 403 | |||
| 404 | if (dailySpend > DAILY_BUDGET) { | ||
| 405 | console.error(`⚠️ DAILY BUDGET EXCEEDED: $${dailySpend.toFixed(2)}`); | ||
| 406 | // Disable AI processing until tomorrow | ||
| 407 | throw new Error('Daily budget exceeded'); | ||
| 408 | } | ||
| 409 | |||
| 410 | if (monthlySpend > MONTHLY_BUDGET) { | ||
| 411 | console.error(`🚨 MONTHLY BUDGET EXCEEDED: $${monthlySpend.toFixed(2)}`); | ||
| 412 | // Send alert email | ||
| 413 | sendAlertEmail('Budget exceeded!'); | ||
| 414 | } | ||
| 415 | } | ||
| 416 | |||
| 417 | function calculateCost(tokens: number, model: string): number { | ||
| 418 | const pricing = { | ||
| 419 | 'claude-sonnet-4.5': { input: 0.003, output: 0.015 }, | ||
| 420 | 'claude-haiku-4': { input: 0.0008, output: 0.004 } | ||
| 421 | }; | ||
| 422 | |||
| 423 | // Simplified: average of input/output | ||
| 424 | const avgPrice = (pricing[model].input + pricing[model].output) / 2; | ||
| 425 | return (tokens / 1000000) * avgPrice; | ||
| 426 | } | ||
| 427 | {{/code}} | ||
| 428 | |||
| 429 | ==== 5.3 Tiered Model Routing (40% Cost Savings) ==== | ||
| 430 | |||
| 431 | {{code language="typescript"}} | ||
| 432 | // llm-router.ts | ||
| 433 | export class LLMRouter { | ||
| 434 | async routeRequest(task: AITask): Promise<string> { | ||
| 435 | switch (task.type) { | ||
| 436 | case 'EXTRACT_CLAIMS': | ||
| 437 | // Simple extraction - use Haiku (cheap) | ||
| 438 | return 'claude-haiku-4'; | ||
| 439 | |||
| 440 | case 'EXTRACT_FACTS': | ||
| 441 | // Simple extraction - use Haiku (cheap) | ||
| 442 | return 'claude-haiku-4'; | ||
| 443 | |||
| 444 | case 'UNDERSTAND_ARTICLE': | ||
| 445 | // Complex reasoning - use Sonnet | ||
| 446 | return 'claude-sonnet-4.5'; | ||
| 447 | |||
| 448 | case 'GENERATE_VERDICT': | ||
| 449 | // Complex synthesis - use Sonnet | ||
| 450 | return 'claude-sonnet-4.5'; | ||
| 451 | |||
| 452 | default: | ||
| 453 | return 'claude-sonnet-4.5'; | ||
| 454 | } | ||
| 455 | } | ||
| 456 | } | ||
| 457 | |||
| 458 | // Usage: | ||
| 459 | const model = await llmRouter.routeRequest({ type: 'EXTRACT_CLAIMS' }); | ||
| 460 | const result = await anthropic.messages.create({ | ||
| 461 | model: model, | ||
| 462 | max_tokens: 1024, | ||
| 463 | messages: [...] | ||
| 464 | }); | ||
| 465 | {{/code}} | ||
| 466 | |||
| 467 | ==== 5.4 Claim Caching (70% Cost Savings) ==== | ||
| 468 | |||
| 469 | See "Separated Architecture Implementation Guide" for full details. | ||
| 470 | |||
| 471 | {{code language="typescript"}} | ||
| 472 | // Quick example: | ||
| 473 | async function analyzeClaimWithCache(claim: string): Promise<Verdict> { | ||
| 474 | const cached = await cache.get(claim); | ||
| 475 | if (cached) { | ||
| 476 | return cached; // Save 100% of cost for this claim | ||
| 477 | } | ||
| 478 | |||
| 479 | const verdict = await analyzeClaimFull(claim); | ||
| 480 | await cache.set(claim, verdict, 7); // 7-day TTL | ||
| 481 | return verdict; | ||
| 482 | } | ||
| 483 | {{/code}} | ||
| 484 | |||
| 485 | ---- | ||
| 486 | |||
| 487 | === Step 6: Authentication for Beta Users === | ||
| 488 | |||
| 489 | ==== 6.1 Simple JWT-based Auth ==== | ||
| 490 | |||
| 491 | {{code language="typescript"}} | ||
| 492 | // auth.ts | ||
| 493 | import jwt from 'jsonwebtoken'; | ||
| 494 | import bcrypt from 'bcrypt'; | ||
| 495 | |||
| 496 | const JWT_SECRET = process.env.JWT_SECRET; | ||
| 497 | |||
| 498 | // Beta user allowlist (stored in DB) | ||
| 499 | const BETA_USERS = [ | ||
| 500 | { email: 'user1@example.com', password: '$2b$10$...' }, | ||
| 501 | { email: 'user2@example.com', password: '$2b$10$...' } | ||
| 502 | ]; | ||
| 503 | |||
| 504 | export async function login(email: string, password: string) { | ||
| 505 | const user = await db.query( | ||
| 506 | 'SELECT * FROM users WHERE email = $1 AND is_beta_user = true', | ||
| 507 | [email] | ||
| 508 | ); | ||
| 509 | |||
| 510 | if (!user.rows[0]) { | ||
| 511 | throw new Error('Not authorized for beta'); | ||
| 512 | } | ||
| 513 | |||
| 514 | const valid = await bcrypt.compare(password, user.rows[0].password_hash); | ||
| 515 | if (!valid) { | ||
| 516 | throw new Error('Invalid credentials'); | ||
| 517 | } | ||
| 518 | |||
| 519 | const token = jwt.sign( | ||
| 520 | { userId: user.rows[0].id, email: user.rows[0].email }, | ||
| 521 | JWT_SECRET, | ||
| 522 | { expiresIn: '7d' } | ||
| 523 | ); | ||
| 524 | |||
| 525 | return { token, user: user.rows[0] }; | ||
| 526 | } | ||
| 527 | |||
| 528 | export function authenticateToken(req, res, next) { | ||
| 529 | const token = req.headers.authorization?.split(' ')[1]; | ||
| 530 | |||
| 531 | if (!token) { | ||
| 532 | return res.status(401).json({ error: 'No token provided' }); | ||
| 533 | } | ||
| 534 | |||
| 535 | try { | ||
| 536 | const decoded = jwt.verify(token, JWT_SECRET); | ||
| 537 | req.user = decoded; | ||
| 538 | next(); | ||
| 539 | } catch (err) { | ||
| 540 | return res.status(403).json({ error: 'Invalid token' }); | ||
| 541 | } | ||
| 542 | } | ||
| 543 | {{/code}} | ||
| 544 | |||
| 545 | ==== 6.2 Beta User Signup (Manual Approval) ==== | ||
| 546 | |||
| 547 | {{code language="typescript"}} | ||
| 548 | // POST /api/beta-signup | ||
| 549 | export async function betaSignup(req, res) { | ||
| 550 | const { email, name, reason } = req.body; | ||
| 551 | |||
| 552 | // Store request for manual review | ||
| 553 | await db.query(` | ||
| 554 | INSERT INTO beta_signup_requests (email, name, reason, status) | ||
| 555 | VALUES ($1, $2, $3, 'pending') | ||
| 556 | `, [email, name, reason]); | ||
| 557 | |||
| 558 | res.json({ | ||
| 559 | message: 'Thank you! Your request has been submitted for review.' | ||
| 560 | }); | ||
| 561 | |||
| 562 | // Notify admin | ||
| 563 | await sendEmail({ | ||
| 564 | to: 'admin@factharbor.org', | ||
| 565 | subject: 'New beta signup request', | ||
| 566 | body: `${name} (${email}) requested beta access: ${reason}` | ||
| 567 | }); | ||
| 568 | } | ||
| 569 | |||
| 570 | // Admin approves via /admin/beta-requests | ||
| 571 | export async function approveBetaUser(req, res) { | ||
| 572 | const { requestId } = req.params; | ||
| 573 | const { approved } = req.body; | ||
| 574 | |||
| 575 | if (approved) { | ||
| 576 | // Create user account | ||
| 577 | const tempPassword = generateRandomPassword(); | ||
| 578 | const passwordHash = await bcrypt.hash(tempPassword, 10); | ||
| 579 | |||
| 580 | await db.query(` | ||
| 581 | INSERT INTO users (email, password_hash, is_beta_user) | ||
| 582 | SELECT email, $1, true | ||
| 583 | FROM beta_signup_requests | ||
| 584 | WHERE id = $2 | ||
| 585 | `, [passwordHash, requestId]); | ||
| 586 | |||
| 587 | // Send welcome email with temp password | ||
| 588 | const request = await db.query( | ||
| 589 | 'SELECT email FROM beta_signup_requests WHERE id = $1', | ||
| 590 | [requestId] | ||
| 591 | ); | ||
| 592 | |||
| 593 | await sendEmail({ | ||
| 594 | to: request.rows[0].email, | ||
| 595 | subject: 'Welcome to FactHarbor Beta!', | ||
| 596 | body: `Your temporary password: ${tempPassword}\n\nPlease log in and change it.` | ||
| 597 | }); | ||
| 598 | } | ||
| 599 | |||
| 600 | // Update request status | ||
| 601 | await db.query( | ||
| 602 | 'UPDATE beta_signup_requests SET status = $1 WHERE id = $2', | ||
| 603 | [approved ? 'approved' : 'rejected', requestId] | ||
| 604 | ); | ||
| 605 | |||
| 606 | res.json({ success: true }); | ||
| 607 | } | ||
| 608 | {{/code}} | ||
| 609 | |||
| 610 | ---- | ||
| 611 | |||
| 612 | === Step 7: Monitoring and Alerts === | ||
| 613 | |||
| 614 | ==== 7.1 Health Check Endpoint ==== | ||
| 615 | |||
| 616 | {{code language="typescript"}} | ||
| 617 | // GET /health | ||
| 618 | export async function healthCheck(req, res) { | ||
| 619 | const checks = { | ||
| 620 | api: 'ok', | ||
| 621 | database: await checkDatabase(), | ||
| 622 | redis: await checkRedis(), | ||
| 623 | budgetStatus: await checkBudget() | ||
| 624 | }; | ||
| 625 | |||
| 626 | const allHealthy = Object.values(checks).every(v => v === 'ok'); | ||
| 627 | |||
| 628 | res.status(allHealthy ? 200 : 503).json({ | ||
| 629 | status: allHealthy ? 'healthy' : 'degraded', | ||
| 630 | checks, | ||
| 631 | timestamp: new Date().toISOString() | ||
| 632 | }); | ||
| 633 | } | ||
| 634 | |||
| 635 | async function checkDatabase(): Promise<string> { | ||
| 636 | try { | ||
| 637 | await db.query('SELECT 1'); | ||
| 638 | return 'ok'; | ||
| 639 | } catch (err) { | ||
| 640 | return 'error'; | ||
| 641 | } | ||
| 642 | } | ||
| 643 | |||
| 644 | async function checkBudget(): Promise<string> { | ||
| 645 | const today = await db.query(` | ||
| 646 | SELECT SUM(cost) as total | ||
| 647 | FROM ai_usage_log | ||
| 648 | WHERE date = CURRENT_DATE | ||
| 649 | `); | ||
| 650 | |||
| 651 | const spent = today.rows[0]?.total || 0; | ||
| 652 | if (spent > DAILY_BUDGET) return 'exceeded'; | ||
| 653 | if (spent > DAILY_BUDGET * 0.8) return 'warning'; | ||
| 654 | return 'ok'; | ||
| 655 | } | ||
| 656 | {{/code}} | ||
| 657 | |||
| 658 | ==== 7.2 Daily Budget Report ==== | ||
| 659 | |||
| 660 | {{code language="typescript"}} | ||
| 661 | // Run daily via cron job or scheduled task | ||
| 662 | export async function sendDailyReport() { | ||
| 663 | const stats = await db.query(` | ||
| 664 | SELECT | ||
| 665 | COUNT(*) as total_analyses, | ||
| 666 | COUNT(DISTINCT user_id) as active_users, | ||
| 667 | SUM(cost) as total_cost, | ||
| 668 | AVG(processing_time_ms) as avg_processing_time | ||
| 669 | FROM analysis_log | ||
| 670 | WHERE date = CURRENT_DATE - INTERVAL '1 day' | ||
| 671 | `); | ||
| 672 | |||
| 673 | const cacheStats = await db.query(` | ||
| 674 | SELECT | ||
| 675 | COUNT(*) as total_claims, | ||
| 676 | SUM(access_count - 1) as cache_hits | ||
| 677 | FROM ClaimVerdict | ||
| 678 | WHERE created_at >= CURRENT_DATE - INTERVAL '1 day' | ||
| 679 | `); | ||
| 680 | |||
| 681 | await sendEmail({ | ||
| 682 | to: 'admin@factharbor.org', | ||
| 683 | subject: `FactHarbor Daily Report - ${new Date().toLocaleDateString()}`, | ||
| 684 | body: ` | ||
| 685 | Total Analyses: ${stats.rows[0].total_analyses} | ||
| 686 | Active Users: ${stats.rows[0].active_users} | ||
| 687 | AI Cost: $${stats.rows[0].total_cost.toFixed(2)} | ||
| 688 | Cache Hits: ${cacheStats.rows[0].cache_hits} | ||
| 689 | Avg Processing Time: ${stats.rows[0].avg_processing_time}ms | ||
| 690 | ` | ||
| 691 | }); | ||
| 692 | } | ||
| 693 | {{/code}} | ||
| 694 | |||
| 695 | ==== 7.3 Set Up Fly.io Monitoring ==== | ||
| 696 | |||
| 697 | {{code language="bash"}} | ||
| 698 | # View metrics | ||
| 699 | fly dashboard | ||
| 700 | |||
| 701 | # Set up alerts (in Fly.io dashboard) | ||
| 702 | # Alert if: | ||
| 703 | # - Response time > 2s | ||
| 704 | # - Error rate > 5% | ||
| 705 | # - Memory usage > 200MB | ||
| 706 | {{/code}} | ||
| 707 | |||
| 708 | ---- | ||
| 709 | |||
| 710 | == Cost Breakdown Analysis == | ||
| 711 | |||
| 712 | === Infrastructure Costs === | ||
| 713 | |||
| 714 | |= Service |= Free Tier |= Usage Estimate |= Monthly Cost | ||
| 715 | | **Fly.io App** | 3 VMs (256MB each) | 1 VM used | **$0** | ||
| 716 | | **Fly.io Postgres** | 3GB storage | 1GB used | **$0** | ||
| 717 | | **Upstash Redis** | 10k cmds/day | ~5k/day | **$0** | ||
| 718 | | **Cloudflare Pages** | Unlimited | Frontend hosting | **$0** | ||
| 719 | | **Domain (optional)** | N/A | factharbor.org | ~$12/year | ||
| 720 | |||
| 721 | **Total Infrastructure: $0/month** (or $1/month if you count domain) | ||
| 722 | |||
| 723 | ---- | ||
| 724 | |||
| 725 | === AI Costs (Claude API) === | ||
| 726 | |||
| 727 | **Scenario:** 50 beta users, 10 analyses/user/month = 500 total analyses | ||
| 728 | |||
| 729 | **With All Optimizations:**\\ | ||
| 730 | * Claim caching (70% hit rate after 1 week) | ||
| 731 | * Tiered models (Haiku for extraction, Sonnet for reasoning) | ||
| 732 | |||
| 733 | |= Stage |= Model |= Tokens/Analysis |= Cost/1M tokens |= Cost/Analysis |= Total (500) | ||
| 734 | | Extract Claims | Haiku | 2,000 | $0.80 | $0.0016 | $0.80 | ||
| 735 | | Extract Facts | Haiku | 5,000 | $0.80 | $0.0040 | $2.00 | ||
| 736 | | Generate Verdict | Sonnet | 3,000 | $3.00 | $0.0090 | $4.50 | ||
| 737 | |||
| 738 | **Before caching:** $7.30/month\\ | ||
| 739 | **After 70% cache hit rate:** $7.30 × 0.30 = **$2.19/month** | ||
| 740 | |||
| 741 | ---- | ||
| 742 | |||
| 743 | === Total Monthly Cost: $2-3 === | ||
| 744 | |||
| 745 | **Best case:** $2.19 (with high cache hit rate)\\ | ||
| 746 | **Worst case:** $7.30 (no caching, month 1)\\ | ||
| 747 | **Realistic:** $3-5 (moderate caching) | ||
| 748 | |||
| 749 | ---- | ||
| 750 | |||
| 751 | == Scaling Plan == | ||
| 752 | |||
| 753 | === When to Upgrade? === | ||
| 754 | |||
| 755 | |= Metric |= Free Tier Limit |= Action When Reached | ||
| 756 | | **Users** | 50-100 | Stay on free tier, add waitlist | ||
| 757 | | **Analyses/day** | 100 | Increase rate limits slowly | ||
| 758 | | **Database size** | 3GB | Archive old data, or upgrade to $7/mo tier | ||
| 759 | | **Memory usage** | 256MB | Optimize code, or add 1 more free VM | ||
| 760 | | **Monthly AI cost** | $10 | Seek funding/donations before scaling | ||
| 761 | |||
| 762 | === Upgrade Path === | ||
| 763 | |||
| 764 | **Phase 1: Free Tier (Current)**\\ | ||
| 765 | * 0-50 users | ||
| 766 | * $0-5/month | ||
| 767 | * Manual beta approvals | ||
| 768 | |||
| 769 | **Phase 2: Hobby Tier ($10-20/month)**\\ | ||
| 770 | * 50-200 users | ||
| 771 | * Upgrade Fly.io to 512MB VMs ($5/mo) | ||
| 772 | * Upgrade Upstash to paid tier ($10/mo) | ||
| 773 | * AI costs: $5-10/month | ||
| 774 | |||
| 775 | **Phase 3: Growth Tier ($50-100/month)**\\ | ||
| 776 | * 200-1000 users | ||
| 777 | * Add CDN, monitoring, backups | ||
| 778 | * Consider sponsorships/donations | ||
| 779 | |||
| 780 | ---- | ||
| 781 | |||
| 782 | == Deployment Checklist == | ||
| 783 | |||
| 784 | === Pre-Deployment === | ||
| 785 | |||
| 786 | * [ ] Backend API containerized and tested locally | ||
| 787 | * [ ] Frontend built and tested | ||
| 788 | * [ ] Database schema created | ||
| 789 | * [ ] Environment variables documented | ||
| 790 | * [ ] Rate limiting implemented | ||
| 791 | * [ ] Budget monitoring implemented | ||
| 792 | * [ ] Authentication system tested | ||
| 793 | |||
| 794 | === Fly.io Deployment === | ||
| 795 | |||
| 796 | * [ ] Fly.io account created | ||
| 797 | * [ ] PostgreSQL database created | ||
| 798 | * [ ] Upstash Redis created | ||
| 799 | * [ ] Secrets configured ({{code}}fly secrets set{{/code}}) | ||
| 800 | * [ ] {{code}}fly.toml{{/code}} configured | ||
| 801 | * [ ] Health check endpoint working | ||
| 802 | * [ ] Deployed ({{code}}fly deploy{{/code}}) | ||
| 803 | * [ ] Logs reviewed ({{code}}fly logs{{/code}}) | ||
| 804 | |||
| 805 | === Cloudflare Pages Deployment === | ||
| 806 | |||
| 807 | * [ ] Frontend repo pushed to GitHub | ||
| 808 | * [ ] Cloudflare Pages connected to repo | ||
| 809 | * [ ] Build settings configured | ||
| 810 | * [ ] Environment variables set | ||
| 811 | * [ ] Custom domain configured (optional) | ||
| 812 | * [ ] HTTPS enabled | ||
| 813 | |||
| 814 | === Post-Deployment === | ||
| 815 | |||
| 816 | * [ ] Health check returns 200 | ||
| 817 | * [ ] Frontend loads correctly | ||
| 818 | * [ ] API requests work | ||
| 819 | * [ ] Authentication works | ||
| 820 | * [ ] Rate limiting works | ||
| 821 | * [ ] Budget alerts configured | ||
| 822 | * [ ] Daily reports configured | ||
| 823 | * [ ] Backup strategy defined | ||
| 824 | |||
| 825 | ---- | ||
| 826 | |||
| 827 | == Troubleshooting == | ||
| 828 | |||
| 829 | === Issue: Fly.io App Not Starting === | ||
| 830 | |||
| 831 | {{code language="bash"}} | ||
| 832 | # Check logs | ||
| 833 | fly logs | ||
| 834 | |||
| 835 | # Common issues: | ||
| 836 | # - Wrong PORT (must be 8080) | ||
| 837 | # - Missing environment variables | ||
| 838 | # - Database connection failed | ||
| 839 | |||
| 840 | # Debug locally: | ||
| 841 | fly ssh console | ||
| 842 | {{/code}} | ||
| 843 | |||
| 844 | === Issue: Database Connection Failed === | ||
| 845 | |||
| 846 | {{code language="bash"}} | ||
| 847 | # Verify database is running | ||
| 848 | fly postgres list | ||
| 849 | |||
| 850 | # Check connection string | ||
| 851 | fly postgres connect -a factharbor-db | ||
| 852 | |||
| 853 | # Test from app | ||
| 854 | fly ssh console -a factharbor-api | ||
| 855 | # Inside container: | ||
| 856 | psql $DATABASE_URL | ||
| 857 | {{/code}} | ||
| 858 | |||
| 859 | === Issue: Rate Limits Not Working === | ||
| 860 | |||
| 861 | {{code language="typescript"}} | ||
| 862 | // Verify Redis connection | ||
| 863 | import Redis from 'ioredis'; | ||
| 864 | const redis = new Redis(process.env.REDIS_URL); | ||
| 865 | |||
| 866 | redis.ping().then(() => { | ||
| 867 | console.log('Redis connected!'); | ||
| 868 | }).catch(err => { | ||
| 869 | console.error('Redis error:', err); | ||
| 870 | }); | ||
| 871 | {{/code}} | ||
| 872 | |||
| 873 | === Issue: High AI Costs === | ||
| 874 | |||
| 875 | # Check cache hit rate: | ||
| 876 | {{code language="sql"}} | ||
| 877 | SELECT | ||
| 878 | COUNT(*) as total_claims, | ||
| 879 | AVG(access_count) as avg_reuses | ||
| 880 | FROM ClaimVerdict; | ||
| 881 | {{/code}} | ||
| 882 | |||
| 883 | # Verify tiered model routing: | ||
| 884 | {{code language="typescript"}} | ||
| 885 | // Log every LLM call | ||
| 886 | console.log(`AI Request: ${task.type} → ${model} → ${tokens} tokens → $${cost}`); | ||
| 887 | {{/code}} | ||
| 888 | |||
| 889 | # Implement hard budget limit: | ||
| 890 | {{code language="typescript"}} | ||
| 891 | if (dailySpend > DAILY_BUDGET) { | ||
| 892 | throw new Error('Budget exceeded - AI disabled for today'); | ||
| 893 | } | ||
| 894 | {{/code}} | ||
| 895 | |||
| 896 | ---- | ||
| 897 | |||
| 898 | == Alternative: Even Cheaper Option (Vercel + Supabase) == | ||
| 899 | |||
| 900 | If Fly.io seems too complex: | ||
| 901 | |||
| 902 | {{code}} | ||
| 903 | Frontend: Vercel (free, better DX than Cloudflare) | ||
| 904 | Backend: Vercel Serverless Functions (free tier: 100GB-hrs) | ||
| 905 | Database: Supabase (free tier: 500MB, 2 projects) | ||
| 906 | Redis: Upstash (same as above) | ||
| 907 | |||
| 908 | Pros: Even simpler deployment (git push) | ||
| 909 | Cons: Less control, harder to migrate later | ||
| 910 | {{/code}} | ||
| 911 | |||
| 912 | ---- | ||
| 913 | |||
| 914 | == Security Considerations == | ||
| 915 | |||
| 916 | === 1. Protect API Keys === | ||
| 917 | |||
| 918 | {{code language="bash"}} | ||
| 919 | # NEVER commit secrets to git | ||
| 920 | echo ".env" >> .gitignore | ||
| 921 | echo "fly.toml" >> .gitignore # Contains secrets | ||
| 922 | |||
| 923 | # Use fly secrets instead | ||
| 924 | fly secrets set ANTHROPIC_API_KEY="sk-ant-..." | ||
| 925 | {{/code}} | ||
| 926 | |||
| 927 | === 2. Enable CORS Properly === | ||
| 928 | |||
| 929 | {{code language="typescript"}} | ||
| 930 | import cors from 'cors'; | ||
| 931 | |||
| 932 | app.use(cors({ | ||
| 933 | origin: [ | ||
| 934 | 'https://factharbor.pages.dev', | ||
| 935 | 'https://factharbor.org' | ||
| 936 | ], | ||
| 937 | credentials: true | ||
| 938 | })); | ||
| 939 | {{/code}} | ||
| 940 | |||
| 941 | === 3. Rate Limit All Endpoints === | ||
| 942 | |||
| 943 | {{code language="typescript"}} | ||
| 944 | // Not just /analyze, but also /login, /signup | ||
| 945 | app.use('/api/login', rateLimitMiddleware); | ||
| 946 | app.use('/api/analyze', rateLimitMiddleware); | ||
| 947 | app.use('/api/beta-signup', rateLimitMiddleware); | ||
| 948 | {{/code}} | ||
| 949 | |||
| 950 | === 4. Validate All Inputs === | ||
| 951 | |||
| 952 | {{code language="typescript"}} | ||
| 953 | import { z } from 'zod'; | ||
| 954 | |||
| 955 | const AnalyzeRequestSchema = z.object({ | ||
| 956 | url: z.string().url(), | ||
| 957 | userId: z.string().uuid().optional() | ||
| 958 | }); | ||
| 959 | |||
| 960 | app.post('/api/analyze', async (req, res) => { | ||
| 961 | const result = AnalyzeRequestSchema.safeParse(req.body); | ||
| 962 | if (!result.success) { | ||
| 963 | return res.status(400).json({ error: result.error }); | ||
| 964 | } | ||
| 965 | |||
| 966 | // Process validated data | ||
| 967 | const { url } = result.data; | ||
| 968 | // ... | ||
| 969 | }); | ||
| 970 | {{/code}} | ||
| 971 | |||
| 972 | ---- | ||
| 973 | |||
| 974 | == Success Metrics == | ||
| 975 | |||
| 976 | Track these weekly: | ||
| 977 | |||
| 978 | * [ ] **Uptime:** >99% (check Fly.io status) | ||
| 979 | * [ ] **Response time:** <2s average (check logs) | ||
| 980 | * [ ] **Daily cost:** <$0.50 (check budget monitor) | ||
| 981 | * [ ] **Cache hit rate:** >40% after week 2 | ||
| 982 | * [ ] **Active users:** Growing steadily | ||
| 983 | * [ ] **Error rate:** <1% | ||
| 984 | |||
| 985 | ---- | ||
| 986 | |||
| 987 | == Next Steps After Beta == | ||
| 988 | |||
| 989 | When ready to scale: | ||
| 990 | |||
| 991 | # **Seek funding/donations** before increasing usage limits | ||
| 992 | # **Add payment system** (Stripe) if going subscription model | ||
| 993 | # **Upgrade infrastructure** gradually based on metrics | ||
| 994 | # **Implement CDN** for faster global access | ||
| 995 | # **Add monitoring** (Sentry, DataDog, etc.) | ||
| 996 | # **Hire DevOps** if growing beyond 1000 users | ||
| 997 | |||
| 998 | ---- | ||
| 999 | |||
| 1000 | == Resources == | ||
| 1001 | |||
| 1002 | * **Fly.io Docs:** https://fly.io/docs | ||
| 1003 | * **Upstash Docs:** https://docs.upstash.com | ||
| 1004 | * **Cloudflare Pages:** https://pages.cloudflare.com | ||
| 1005 | * **Anthropic Pricing:** https://www.anthropic.com/pricing | ||
| 1006 | * **Rate Limiter Library:** https://github.com/animir/node-rate-limiter-flexible | ||
| 1007 | |||
| 1008 | ---- | ||
| 1009 | |||
| 1010 | == Support Contacts == | ||
| 1011 | |||
| 1012 | * **Fly.io Community:** https://community.fly.io | ||
| 1013 | * **Fly.io Support:** support@fly.io (for paying customers) | ||
| 1014 | * **This Guide Author:** Claude Code (2026-01-02) | ||
| 1015 | |||
| 1016 | ---- | ||
| 1017 | |||
| 1018 | == Change Log == | ||
| 1019 | |||
| 1020 | |= Version |= Date |= Author |= Changes | ||
| 1021 | | 1.0 | 2026-01-02 | Claude Code | Initial hosting guide for zero-cost beta | ||
| 1022 |