# Hosting & Capacity Plan 🟒 **Decided 2026-06-13.** Where the Super System runs on AWS, **how many users/day it can serve**, and **what the bottleneck is** at each tier. --- ## AWS topology ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Users ───────────▢ β”‚ CloudFront (CDN) β”‚ β”‚ www. (Nuxt assets) Β· app. Β· admin. (SPA assets)β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ dynamic / SSR / API calls β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β–Ό β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Nuxt SSR server β”‚ β”‚ Elastic Beanstalkβ”‚ β”‚ (app/admin SPAs β”‚ β”‚ (www) β€” small EC2β”‚ β”‚ Laravel API β”‚ β”‚ are static β†’ β”‚ β”‚ behind CF β”‚ β”‚ (api.) autoscale β”‚ β”‚ pure CloudFront)β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β–Ό β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ EC2 PostgreSQL β”‚ β”‚ EC2 Redis β”‚ β”‚ S3 (media, WAL-G β”‚ β”‚ 2 DBs: laff + β”‚ β”‚ Horizon+cacheβ”‚ β”‚ backups, assets) β”‚ β”‚ laff_audit β”‚ β”‚ +analytics β”‚ β”‚ β”‚ β”‚ WAL-G β†’ S3 (PITR)β”‚ β”‚ +analytics β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ stream (AOF)β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ### Service choices (and the reasoning) | Tier | Service | Notes | |---|---|---| | CDN / static | **CloudFront** | `app.` + `admin.` are static SPA bundles β†’ served entirely from CF (effectively infinite scale). `www.` (Nuxt) assets cached on CF, SSR misses hit the Nuxt server. | | Nuxt SSR | small **EC2** behind CF | only renders public pages on cache-miss; cacheable. | | API | **Elastic Beanstalk** (Laravel) | autoscaling group of EC2. *Note:* Beanstalk works but is dated; ECS Fargate / App Runner are the modern alternatives β€” revisit if desired. | | **Database** | **EC2 PostgreSQL** β€” one server, **two databases** (`laff` functional + `laff_audit`) | self-managed, split-aware (lift `laff_audit` to its own box later on a trigger). **WAL-G β†’ S3** for point-in-time recovery (see backup doc). Managed RDS rejected on cost; PITR via WAL-G gives ~seconds-of-loss recovery for S3 cost only. | | Cache/queue | **EC2 Redis** | Horizon queues + cache + analytics Stream. AOF persistence; rebuildable so no formal backup needed. | | Storage | **S3** | course media/video, WAL-G backups, SPA/Nuxt static assets, spatie dumps. | ### Backups / recovery (decided) - **Postgres:** WAL-G (or pgBackRest) streaming WAL + periodic base backup to S3 β†’ **point-in-time recovery to any second.** Optional `spatie/laravel-backup` dump to S3 as a portable second copy. **Failover = manual + runbook** (accepted trade-off vs RDS auto-failover, to save cost). Optional cheap streaming replica later for fast failover. - **Redis:** AOF persistence only β€” rebuildable state (cache/sessions/queue) + short-window analytics stream. No backup gap worth closing. --- ## The capacity question: how many users/day can we serve? ### Where bottlenecks actually live (in order) 1. **Database** ← almost always the real ceiling. 2. **Redis** ← only if the analytics firehose is mismanaged (see queues doc β€” it isn't). 3. **API compute** ← horizontally scalable on Beanstalk; rarely the wall. 4. **CloudFront / SPAs** ← effectively never the bottleneck. Infinite-ish. > **The front end is not your limit. The database is.** So the capacity plan is > mostly a database-sizing exercise. ### The single most important capacity fact **The entire functional dataset is ~536 MB** (and the genuinely hot subset is far smaller). That **fits entirely in RAM** on even a small DB instance. So the DB is **not disk-I/O bound** β€” it serves reads from memory. The ceiling becomes **CPU + connection count**, which is a *much* higher wall than disk. ### Real measured load (today, from live data) | Metric | Value | Implication | |---|---|---| | Website traffic | ~100,000 clicks/month (~3.3k/day) | ~0.04 req/s avg from clicks | | Orders (writes) | ~60–90/day, peak 188/day | **transactional writes are trivial** | | Peak signups ever | 117 in one day | tiny burst | | Projected analytics events | ~50k/day (~10/s peak) | handled async, off the hot path | **Writes are a non-issue.** The load is **read-dominated** (browsing courses, lessons, dashboards). That's exactly what an in-RAM Postgres + Redis cache eats for breakfast. --- ## Capacity model (read-dominated, cache-fronted) Assumptions (deliberately conservative): - A logged-in member session β‰ˆ **15 page views**, each β‰ˆ **3 DB-backed API calls** β†’ ~45 queries/session, but **~80% served from Redis cache** β†’ ~9 real DB queries/session reaching Postgres. - Avg cached query ~1 ms; avg DB query ~5 ms. | Tier | Conservative sustained capacity | Bottleneck signal | |---|---|---| | **CloudFront + SPAs** | millions/day | none realistically | | **Nuxt SSR (1 small EC2)** | ~tens of req/s (cache-miss only; most served by CF) | CPU on SSR render | | **Laravel API (Beanstalk, 2Γ— small EC2)** | ~hundreds of req/s | CPU β†’ **autoscale adds nodes** | | **Redis (1 instance)** | tens of thousands ops/s | memory eviction (see queues doc) | | **Postgres (1 small EC2, e.g. 2 vCPU / 8GB)** | **~hundreds of queries/s sustained** | **CPU / connections ← THE WALL** | ### Translating to users/day At ~9 DB queries/session and a conservative ~200 sustained queries/s the small DB can hold (with headroom, not maxed): - ~200 q/s Γ· 9 q/session β‰ˆ **~22 sessions/s** sustained. - Real traffic is bursty, not flat. Assuming busy traffic compresses into ~6 active hours and a 4Γ— peak-to-average factor: - **~22 sessions/s sustained** β†’ comfortably **hundreds of thousands of member sessions/day** before the DB is the bottleneck. > **Headline for planning:** on a *single small* EC2 Postgres, the system serves > on the order of **100,000+ member sessions/day** comfortably β€” roughly **30Γ—+ > current traffic** (~3.3k visits/day today). The first tier to strain is the > **database CPU/connections**, and the first lever is **read replicas**, not a > bigger everything. ### Numbers are illustrative These are planning estimates from measured data shapes, **not** load-test results. Validate with a real load test before launch (see open questions). The *relative* story β€” DB is the ceiling, front end isn't β€” is robust regardless. --- ## Scaling levers, in the order you'd pull them 1. **Cache more / longer in Redis** β€” cheapest; pushes DB load down directly. 2. **Add a Postgres read replica** β€” member browsing is read-heavy; replicas multiply read capacity. (EC2 streaming replica; doubles as failover standby.) 3. **Autoscale the API tier** (Beanstalk adds EC2 nodes) β€” when API CPU climbs. 4. **Bigger DB instance** (vertical) β€” simple bump if CPU-bound and replicas aren't enough. 5. **Split analytics Redis β†’ own instance** β€” per the queues-and-redis ladder. 6. **PgBouncer (connection pooler)** in front of Postgres β€” when connection count (not CPU) is the limit; lets the API tier scale wide without exhausting DB connections. > Connection exhaustion is the *sneaky* DB bottleneck: each API node opens DB > connections, so scaling the API tier wide can hit Postgres's connection limit > before its CPU. **PgBouncer** is the standard fix β€” note it early. --- ## Bottleneck summary (the one-liner for the plan) > **Front end (CloudFront/SPAs) scales effectively without limit. The > bottleneck is the PostgreSQL instance β€” specifically CPU and connection count β€” > and at ~536MB of in-RAM data a single small EC2 Postgres comfortably serves > ~100k+ member sessions/day (~30Γ— current traffic). Scale it with caching β†’ > read replicas β†’ PgBouncer, long before vertical upsizing.** See open follow-ups in [../00-index/open-questions.md](../00-index/open-questions.md).