# EC2 Sizing & Scaling Tiers ๐ŸŸก **Planning model, 2026-06-13.** Which EC2 instances to run for **PostgreSQL** and **Redis**, and **what daily capacity each tier unlocks** as we scale up. Companion to [hosting-and-capacity.md](hosting-and-capacity.md) (which establishes *that* Postgres is the bottleneck). This answers: *"so what do we run, and if we make it bigger, how much can we then handle?"* > โš ๏ธ These are **modelled estimates** from measured data shapes, not load-test > results. Use them to pick a starting tier and budget; validate with a real load > test before launch. The *shape* (CPU/connections is the wall, RAM never is) is > robust. --- ## The grounding facts (measured) | Fact | Value | Why it matters for sizing | |---|---|---| | Functional dataset | **~536 MB** | Fits in RAM on the *smallest* prod box โ†’ **never disk-bound** | | Hot tables (users, orders, access) | **~260 MB** | The working set Postgres keeps cached | | Per member session | ~15 page views ร— ~3 API calls โ‰ˆ **45 queries**, **~80% cache hits** โ†’ **~9 DB queries** | The unit we divide capacity by | | Current writes | ~88 orders/day avg, peak 293; ~20 signups/day | **Writes are trivial** โ€” read sizing dominates | | Current traffic | ~100k clicks/mo (~3.3k visits/day) | The baseline we're scaling *above* | | Current analytics events | ~544/day avg, peak 1,802 | Tiny โ€” well inside Redis Stage 0 | ### What ~3.3k visits/day actually means in load Even if **every** visit were a full member session (~9 DB queries): 3,300 ร— 9 โ‰ˆ **30,000 queries/day**. Over a busy ~6-hour window with a 4ร— peak factor that's **~5โ€“6 queries/second at peak.** A small Postgres does *hundreds* of q/s โ€” so today we use **~1โ€“2% of even the smallest sensible instance.** We are sizing for *headroom and growth*, not current need. **Consequence:** because the whole DB lives in RAM, scaling = **adding vCPUs** (query throughput) and **managing connections**. RAM matters only for headroom + Redis, not for fitting the data. --- ## How to read capacity - **Sustained DB throughput** โ‰ˆ a safe fraction (~60% headroom, not maxed) of what the vCPUs can serve for in-RAM queries. - **Member sessions/day** = sustained queries/s รท ~9 DB queries/session, spread over a bursty day (assume traffic compresses into ~6 active hours, ~4ร— peak factor). The headline number is the **comfortable** daily ceiling, not the absolute max. - Numbers assume Redis is doing its job (~80% cache hit). Better caching โ†’ higher numbers; the DB tier is the floor. --- ## PostgreSQL โ€” EC2 tiers (the bottleneck tier) | Tier | Instance | vCPU / RAM | ~Sustained DB q/s | **~Member sessions/day** | ~$/mo (on-demand)* | When | |---|---|---|---|---|---|---| | **โ˜… Today** | `t3.small` | 2 / 2 GB | ~80โ€“120 | **~20kโ€“30k** | ~$15 | **fits current ~3.3k/day with ~8ร— headroom** | | **0 ยท Start (rec.)** | `t3.medium` | 2 / 4 GB | ~150โ€“200 | **~40kโ€“60k** | ~$30 | sized to reality + ~15ร— headroom | | **1 ยท Steady** | `m7g.large` | 2 / 8 GB | ~300โ€“400 | **~150kโ€“200k** | ~$60โ€“75 | sustained growth, no CPU-credit worries | | **2 ยท Growth** | `m7g.xlarge` | 4 / 16 GB | ~700โ€“900 | **~350kโ€“450k** | ~$120โ€“150 | real growth; years of runway | | **3 ยท Heavy** | `m7g.2xlarge` | 8 / 32 GB | ~1,500โ€“1,800 | **~700kโ€“900k** | ~$240โ€“300 | large launches, big webinar spikes | | **4 ยท Big** | `r7g.4xlarge` | 16 / 128 GB | ~3,000โ€“3,800 | **~1.5Mโ€“1.9M** | ~$700โ€“850 | only if you become a very large platform | > **You are at the โ˜… row today.** Current load (~5โ€“6 q/s peak) sits comfortably > inside a `t3.small`. The recommended **start** is one notch up (`t3.medium`) > purely for headroom during the build and early launch โ€” not because traffic > demands it. `t3` instances are burstable: fine here because load is low and > bursty, but watch CPU credits if a webinar drives a sustained spike (that's the > signal to move to `m7g.large`). \* Rough `eu`/`us` on-demand Linux pricing, 2026 ballpark; **Graviton (`g`) instances** (`m7g`, `r7g`) are ARM, ~20% cheaper and great for Postgres. **Reserved/Savings Plans cut ~30โ€“50%.** Treat $ as order-of-magnitude. > **Read replicas multiply this.** Member browsing is read-heavy, so adding one > read replica of the *same* tier roughly **doubles** read capacity (writes still > go to the primary, but writes are trivial here). Two replicas โ‰ˆ 3ร—. This is > cheaper than jumping a whole tier and **doubles as a failover standby**. --- ## Redis โ€” EC2 tiers (rarely the bottleneck) Redis is single-threaded for command execution but does **tens of thousands of ops/s** on a small box, and our analytics firehose is async/batched. So Redis is sized for **memory** (cache + analytics stream buffer), not CPU. | Tier | Instance | vCPU / RAM | Headroom | When | |---|---|---|---|---| | **0 ยท Launch** | `t3.small` | 2 / 2 GB | cache + stream for current scale, easily | MVP | | **1 ยท Steady** | `t3.medium` | 2 / 4 GB | comfortable cache + Horizon + stream | launch โ†’ growth | | **2 ยท Growth** | `m7g.large` | 2 / 8 GB | big cache, large stream backlog tolerance | heavy traffic | | **3 ยท Split** | dedicate `m7g.large` to analytics + another to cache/queue | โ€” | when `evicted_keys > 0` (see queues doc ladder) | firehose | > Redis RAM = (working cache set) + (unflushed analytics stream). At our scale > both are small. The trigger to grow Redis is **eviction**, not CPU โ€” see the > split ladder in [queues-and-redis.md](queues-and-redis.md). --- ## Recommended starting point (sized to reality) ``` Postgres : t3.medium (2 vCPU / 4 GB) ~$30/mo โ† TWO databases: laff + laff_audit Redis : t3.small (2 vCPU / 2 GB) ~$15/mo ``` - Start Postgres at **`t3.medium`** โ€” matches current ~3.3k/day with **~15ร— headroom** for ~$30/mo. We are *nowhere near* needing more (today's peak is ~5โ€“6 q/s; this box does ~150โ€“200). - **Audit is a second *database* (`laff_audit`) on the same server**, not its own instance โ€” at a few hundred audit writes/day there's nothing to isolate yet. App connects to it separately (split-aware), so lifting it to its own box later is an infra change with no code change. (See data-stores-and-logging.md.) - Redis `t3.small` โ€” cache + analytics stream are tiny at current scale. **Whole starting stack โ‰ˆ ~$45/mo** (one PG box + Redis). Move Postgres to `m7g.large`, and/or split `laff_audit` onto its own server, only when a measured signal says so (sustained CPU, or CPU-credit exhaustion on a webinar spike) โ€” not before. This is deliberate cost discipline: pay for the load you have, scale on evidence. --- ## Two different Postgres limits: CPU vs connections The bottleneck is "CPU **and** connections" โ€” but they are different problems with different fixes. Confusing them leads to over-paying for a bigger box that doesn't help. | Limit | What it is | Fix | Scales with instance size? | |---|---|---|---| | **CPU** | query throughput (q/s) | bigger EC2 tier, or read replica | โœ… **Yes** | | **Connections** | concurrent open DB sessions | **PgBouncer** + sane per-node pools + replicas | โŒ **No** | ### Why connections DON'T scale by sizing up Each Postgres connection is a **separate OS process** holding ~5โ€“10 MB, busy or idle. `max_connections` is typically ~100โ€“200, and raising it higher *hurts*: past a few hundred, context-switching drags throughput **down**. Postgres performs best with a **small** number of busy connections (~`vCPUs ร— 2โ€“4`), not thousands of idle ones. So a `t3.medium` and an `m7g.2xlarge` want roughly the *same* modest connection count โ€” sizing up buys **CPU, not connections**. ### Where the pressure comes from (our architecture specifically) The Laravel API runs on **autoscaling Beanstalk** โ€” multiple nodes, each with its own DB connection pool: ``` 3 API nodes ร— ~20 conns = 60 โ†’ fine 10 API nodes ร— ~20 conns = 200 โ†’ hits Postgres max_connections ``` The trap: **scaling the API tier WIDE to serve traffic can exhaust Postgres connections before its CPU is even busy** (connection wall at 30% CPU). ### The fix: PgBouncer (connection pooler) PgBouncer sits between the API and Postgres. The API opens many cheap connections to PgBouncer; it **multiplexes** them onto a small pool of real Postgres connections, reusing each across requests. ``` 100s of API conns โ”€โ–ถ PgBouncer โ”€โ–ถ ~20โ€“25 real Postgres conns (transaction pooling) ``` In **transaction-pooling mode** (standard for web apps) a real connection is held only for one transaction, then reused. ~500 API-side connections ride on ~25 real ones โ†’ the connection wall disappears, leaving CPU (which *does* scale) as the only limit. PgBouncer is tiny, free, battle-tested (it's what RDS Proxy / Supabase do under the hood). PHP/Laravel opens-and-closes per request, so it pairs well. ### Decision โ€” when we add it ๐ŸŸข **Add PgBouncer when the API tier autoscales wide**, not day one. At launch (~5โ€“6 q/s, a couple of API nodes) we're nowhere near the connection limit. **Trigger:** `active_connections` approaching `max_connections`, OR the API autoscaling past ~3โ€“4 nodes. It's a quick drop-in when needed. (Decision 2026-06-13.) --- ## The scaling decision tree (what to do when the DB strains) Pull these levers **in order** โ€” each is cheaper/safer than the next: 1. **Cache more / longer in Redis.** Free throughput; pushes DB load down. Always first. 2. **Add a read replica** (same tier). ~Doubles read capacity, ~+100% of one DB's cost, and gives a failover standby. Best $/capacity lever here. 3. **Vertical bump** (next Postgres tier). Simple; doubles vCPUs โ‰ˆ doubles q/s. 4. **PgBouncer** (connection pooler). When the limit is *connection count* (many API nodes each holding connections), not CPU. Lets the API tier scale wide. 5. **Split analytics Redis** to its own instance (per the queues ladder). > **Watch these signals** to know *which* lever: Postgres **CPU %** (โ†’ replica or > bigger tier), **active connections vs max** (โ†’ PgBouncer), Redis **evicted_keys** > (โ†’ bigger/split Redis), API node **CPU** (โ†’ Beanstalk autoscale). --- ## Headline for planning > **Today's real load (~3.3k visits/day, 88 orders, 20 signups, ~5โ€“6 q/s peak) > uses ~1โ€“2% of even the smallest sensible box.** Start Postgres on a > **`t3.medium` (~$30/mo)** holding **two databases (`laff` + `laff_audit`)** โ†’ > ~40kโ€“60k sessions/day, ~15ร— current โ€” whole stack (Postgres + Redis) **โ‰ˆ ~$45/mo**. > Scale up only on measured signals: > `m7g.large` โ†’ ~150kโ€“200k/day, `m7g.xlarge` โ†’ ~400k/day, and a **read replica** > doubles reads more cheaply than a tier jump (and adds failover). RAM is never > the limit (536 MB dataset); you're buying **vCPUs and connection capacity**.