OMNI1Internal · Engineering Note · 2026-08-22

Scaling to 1,000 concurrent customers

What breaks, what doesn't, and the order in which we fix things. Written for the team — direct, with the honest numbers.

TL;DR — the blueprint already scales; the handwork doesn't. The database is Postgres (since Aug 14), the customer portal is one app with per-customer tokens, and the domain layer serves 1,000 customers with exactly two DNS records. There is no big-bang rebuild. There are four workstreams, and the hardest one is not technical: every manual step per customer must go to zero.

1What 1,000 customers actually means

Numbers first, so nobody argues with adjectives. Rough, honest estimates:

DimensionAt 10 customersAt 1,000Verdict
DNS records for portals22scales free
Portal page loadsa few per day~500–1,500/day, bursty after mailingstrivial for nginx + SSR
Portal payload builds (GSC + compute)on demand, unnoticeable~4,000–8,000 builds/day if naivemust move to a worker
Google Search Console propertieshandful~1,000, each with API quota + OAuth grantquota + onboarding design
DatabasePostgres, light loadPostgres, heavy read + steady writefine if indexes/constraints are complete
Manual steps per new customer~5 (GSC, portal, offer link, video, forms)5 × 1,000 = a full-time departmentthe real bottleneck

2What already scales — and why

One building, many rooms

A customer link has two parts. Everything before the first slash is the building (a domain — registered once, never touched again). Everything after it is the room (a token — one database row, created automatically per customer):

portal.omni1.debuilding · 2 DNS records, ever /_KNrcpFtC5jk…room · one DB row per customer, automatic
Customer #1,000 costs the same DNS work as customer #1: none. Two domains (.ch/.de) exist purely so the link matches the customer's market — the same switch that already picks the sender address and phone number.

The system, as it runs today

flowchart LR
  C["Customer
(token link, no login)"] --> D["portal.omni1.ch / .de
(planned, 2 A-records)
seo.omni-dashboards.com (today)"] T["Team"] --> H["Hub + internal tools
seo.omni-dashboards.com only"] D --> N["nginx
one VPS"] H --> N N --> F["Next.js SSR
(portal, forms)"] N --> A["FastAPI
(one API)"] F --> A A --> P[("Supabase / Postgres
production since Aug 14")] A --> G["Google Search Console API
per-customer property"] W["Windmill
(automation layer)"] --> CL["Close CRM"] W --> DS["DocuSeal"] W --> A classDef hot fill:#1b1416,stroke:#DF1B21,color:#f2f0ee classDef cool fill:#151517,stroke:#2a2a2e,color:#f2f0ee classDef db fill:#12181a,stroke:#1fa971,color:#f2f0ee class C,D hot class N,F,A,H,T,W,CL,DS cool class P,G db
One app, one API, one database. The two customer domains are names on the same door — the app doesn't behave differently per host; market logic lives in the data (flag_code per project), never in the domain.

Already settled and scale-proof: Postgres in production (cutover Aug 14, SQLite kept only as dev copy and rollback point), token-based portals (customer = row, not infrastructure), Windmill as the automation layer, and the two-domain plan (dom-948).

3The four workstreams

3.1 Finish hardening Postgres — don't replace it

The migration is done; the hardening is not. The dangerous part is invisible today: when data moves from SQLite to Postgres the naive way, UNIQUE, FOREIGN KEY, DEFAULT and NOT NULL constraints are silently lost — they live inside SQLite's CREATE TABLE text, not in the schema views a migration script reads. We have documented this exact failure before. With one customer nobody notices. At 300, missing indexes mean slow queries, and missing constraints mean silent data corruption.

3.2 Precompute portal payloads

Today every portal visit may trigger a live build: Search Console queries plus report computation, cached 300 s per project. Fine for ten customers, hostile at a thousand — especially on a 2-vCPU VPS shared with the asset library.

flowchart TD
  subgraph today ["Today — compute on request"]
    V1["Visitor opens portal"] --> M1{"cache fresh?"}
    M1 -->|no| B1["GSC queries + compute
seconds, on the web server"] M1 -->|yes| R1["render"] B1 --> R1 end subgraph target ["Target — compute on schedule"] W2["Worker refreshes every project
on a fixed cycle, off-peak aware"] --> S2[("precomputed read model
one indexed row per project")] V2["Visitor opens portal"] --> S2 S2 --> R2["render in milliseconds"] end classDef hot fill:#1b1416,stroke:#DF1B21,color:#f2f0ee classDef cool fill:#151517,stroke:#2a2a2e,color:#f2f0ee classDef db fill:#12181a,stroke:#1fa971,color:#f2f0ee class V1,B1,M1 hot class R1,V2,W2,R2 cool class S2 db
The visitor stops paying the compute cost; a worker pays it on a schedule. This also caps Search Console API usage at a predictable rate instead of traffic-driven bursts.

Alongside: resize the VPS (a click) or move workers off the web box, and manage GSC API quotas per property — the one external ceiling we don't control.

3.3 Marginal cost per customer → zero

This is the real scaling project. Every step below is charming at ten customers and a full-time job at a thousand:

Step today (manual)At scale (automatic)
Connect customer's Search ConsoleGuided grant in onboarding; verified programmatically
Create the portalCreated by the won-chain the second the contract is signed
Paste the offer link into the portaloffer_engine writes it itself (ops-034)
Record a per-customer welcome videoOne standard video for all (dom-928)
Provision forms / projectAlready one-click — make it zero-click on signature

Rule of thumb for every new feature from now on: “Does this work for 1,000 customers without a human touching each one?”

3.4 Hardening that only matters at mass

4The road, in thresholds

ThresholdMust be done before crossing it
→ 50 customersPostgres hardening (3.1). Everything else keeps up as-is.
→ 300Precomputed payloads + pooling + bigger box (3.2). Onboarding automation well underway (3.3).
→ 1,000GSC quota management, rate limits, monitoring, cert split (3.4). Marginal cost per customer at zero (3.3).

5What we deliberately do not change

6The honest limit

Past these thresholds the bottleneck stops being machines. “Personal support” is a promised deliverable in the offer, and content delivery — however productized — has human review in the loop. The machine roadmap above buys the room for that conversation; it doesn't replace it.

OMNI1 · internal engineering note Source of truth: SSoT items ops-035 (scaling) · dom-948 (portal domains) 2026-08-22