| # Labour Connect — Scaling Guide (20k users) | |
| You're right: **20k registered / 2k DAU / 200–500 peak concurrent / 500–1000 | |
| bookings’ day is comfortably a single-server workload.** This codebase is already | |
| shaped for it. This doc maps your target architecture to what exists and what to | |
| add, in order of impact. | |
| ## Target topology | |
| ``` | |
| Flutter App + React Admin | |
| │ HTTPS (TLS 1.3) | |
| ▼ | |
| Nginx (LB + TLS + rate-limit + static) | |
| │ | |
| ▼ | |
| Gunicorn + Uvicorn workers (FastAPI) ← stateless, scale horizontally | |
| │ │ │ | |
| ▼ ▼ ▼ | |
| Redis PostgreSQL Object Storage (S3) | |
| (cache + (primary + (KYC docs, images) | |
| rate-limit read replica | |
| + Celery at scale) | |
| broker) | |
| │ | |
| ▼ | |
| Background workers (Celery/RQ) ← SMS/OTP, FCM push, image processing | |
| ``` | |
| ## Capacity math (sanity check) | |
| - 2k DAU, peak 500 concurrent. Even at 5 req/user-action, that's a few hundred | |
| req/s **burst**, not sustained. A single 4-vCPU box running Gunicorn handles this. | |
| - 500–1000 bookings/day = **< 1 write/sec average**. The DB is essentially idle; | |
| reads (worker search, listings) dominate → cache those. | |
| ## 1. App server — run it like production | |
| Replace the single Uvicorn dev process with Gunicorn managing Uvicorn workers: | |
| ```bash | |
| gunicorn app.main:app \ | |
| -k uvicorn.workers.UvicornWorker \ | |
| -w 4 --threads 2 \ | |
| -b 0.0.0.0:8000 \ | |
| --timeout 60 --max-requests 1000 --max-requests-jitter 100 | |
| ``` | |
| Rule of thumb: `workers = 2 × vCPU + 1`. Stateless app → add boxes behind Nginx | |
| when needed. **One caveat:** our in-memory rate limiter (`core/rate_limit.py`) is | |
| per-process — with multiple workers/instances, **move it to Redis** (same interface, | |
| swap the dict for Redis INCR + EXPIRE). Until then run `-w 1` or accept per-worker limits. | |
| ## 2. PostgreSQL — already indexed | |
| Move off SQLite to Postgres (just unset `DATABASE_URL`, set `POSTGRES_*`). The | |
| schema already has the indexes that matter at this scale: | |
| - `bookings`: indexed on `customer_id`, `worker_id`, `status` | |
| - `worker_profiles`: indexed on `category_id`; search filters on verified/available | |
| - `users`: unique indexes on `phone`, `email` | |
| At 20k users no sharding/partitioning is needed. Add a **read replica** only when | |
| read latency climbs; route `GET` traffic to it. Connection pooling via `pool_pre_ping` | |
| is on; add **PgBouncer** if you run many app instances. | |
| ## 3. Redis — the highest-leverage addition | |
| Use it for three things (one dependency, big wins): | |
| 1. **Cache** hot reads: category list, worker search results (short TTL 30–60s). | |
| Wrap `crud.category.list_all` and `crud.worker.search` with a cache-aside helper. | |
| 2. **Distributed rate-limiting / OTP lockout** (replaces in-memory store). | |
| 3. **Celery broker** (below). | |
| ## 4. Background workers (Celery/RQ) | |
| Anything slow or external must leave the request path: | |
| - **OTP SMS send** (when `OTP_MOCK=false`) — fire-and-forget task. | |
| - **FCM push notifications** (new booking → worker, status change → customer). | |
| - **Image processing / AV scan** for KYC uploads. | |
| - **Rating recompute** could be async on review create. | |
| ``` | |
| celery -A app.worker worker --concurrency=4 | |
| ``` | |
| (Add `app/worker.py` with a Celery app bound to the Redis broker.) | |
| ## 5. Object storage (S3) | |
| KYC docs + profile images go to **private S3** (not the app disk). The upload | |
| endpoint already centralises this in `services/uploads.py` — swap the local write | |
| for `boto3.put_object` with server-side encryption, return a key, serve via signed URLs. | |
| ## 6. Nginx (LB + edge) | |
| - TLS 1.3 termination, HTTP→HTTPS redirect (HSTS header already emitted by the app). | |
| - `limit_req` zone for a global per-IP throttle (defence beyond app rate-limits). | |
| - Serve the built React admin (`admin/dist`) as static; proxy `/api` to Gunicorn. | |
| - gzip/brotli; cache static assets. | |
| ## Single-box vs scale-out | |
| | Stage | Setup | Good for | | |
| |-------|-------|----------| | |
| | **MVP / launch** | 1 box: Nginx + Gunicorn(4w) + Postgres + Redis; S3 for files | up to ~20–50k users | | |
| | **Growth** | Separate DB (managed RDS) + Redis (ElastiCache); 2–3 app boxes behind ALB; Celery workers | 100k+ | | |
| | **Scale** | Read replica + PgBouncer, autoscaling app tier, CDN for assets | 500k+ | | |
| ## What to add to this repo next (priority order) | |
| 1. `gunicorn` to `requirements.txt` + a `gunicorn.conf.py` (prod run). | |
| 2. Redis-backed `rate_limit` + cache-aside on category/worker reads. | |
| 3. Celery app + tasks for SMS + FCM push. | |
| 4. S3 storage backend in `services/uploads.py`. | |
| 5. `docker-compose.prod.yml`: nginx + api(gunicorn) + postgres + redis + worker. | |
| > Bottom line: the code is already “single-server for 20k” ready. The only | |
| > correctness change required before horizontal scale-out is **moving the rate | |
| > limiter to Redis**; everything else (replica, Celery, S3, CDN) is additive. | |