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.
Numbers first, so nobody argues with adjectives. Rough, honest estimates:
| Dimension | At 10 customers | At 1,000 | Verdict |
|---|---|---|---|
| DNS records for portals | 2 | 2 | scales free |
| Portal page loads | a few per day | ~500–1,500/day, bursty after mailings | trivial for nginx + SSR |
| Portal payload builds (GSC + compute) | on demand, unnoticeable | ~4,000–8,000 builds/day if naive | must move to a worker |
| Google Search Console properties | handful | ~1,000, each with API quota + OAuth grant | quota + onboarding design |
| Database | Postgres, light load | Postgres, heavy read + steady write | fine if indexes/constraints are complete |
| Manual steps per new customer | ~5 (GSC, portal, offer link, video, forms) | 5 × 1,000 = a full-time department | the real bottleneck |
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):
.ch/.de) exist purely so the link matches the customer's
market — the same switch that already picks the sender address and phone number.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
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).
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.
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
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.
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 Console | Guided grant in onboarding; verified programmatically |
| Create the portal | Created by the won-chain the second the contract is signed |
| Paste the offer link into the portal | offer_engine writes it itself (ops-034) |
| Record a per-customer welcome video | One standard video for all (dom-928) |
| Provision forms / project | Already 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?”
portal.omni1.* — the customer domain joins the heartbeat.omni1.ch/.de zones are also touched by
website work. The portal records must be documented and monitored — deleting two lines there
would take down every customer link at once.| Threshold | Must be done before crossing it |
|---|---|
| → 50 customers | Postgres hardening (3.1). Everything else keeps up as-is. |
| → 300 | Precomputed payloads + pooling + bigger box (3.2). Onboarding automation well underway (3.3). |
| → 1,000 | GSC quota management, rate limits, monitoring, cert split (3.4). Marginal cost per customer at zero (3.3). |
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.