What I learned shipping a multi-tenant SaaS mostly solo
The architecture calls I'd make again, the ones I'd undo, and why "boring" infrastructure was the best decision I made while building Forzive.
Building a multi-tenant SaaS by yourself is a series of bets against your own future time. Every clever abstraction is a debt you pay down later, alone, usually at 11pm. After taking Forzive from an empty repo to gyms running their whole operation on it, here's what actually held up — and what I'd do differently.
Boring infrastructure was the best decision I made
The temptation, solo, is to reach for the shiniest stack so the work feels modern. I went the other way: one Postgres database, one Next.js app, one deploy. No microservices, no message queues I didn't need, no Kubernetes. When something broke, there was exactly one place to look.
Multi-tenancy lives in the data layer, not the architecture. Every tenant is a row-scoped slice of the same database, and a single middleware resolves the tenant from the subdomain. That's it. The "interesting" version with a database per tenant would have buried me in migrations and connection management for zero early benefit.
Enforce tenant isolation at the data layer, not by discipline
The scariest bug in multi-tenant software is a query that forgets its WHERE tenantId = ?. You cannot rely on remembering it on every call — at some point you won't. I scoped tenancy in one place: a query helper that refuses to run without a tenant context. If isolation is structural, a tired version of you can't leak data across gyms.
Pair that with seed data that contains two tenants from day one. Most cross-tenant leaks hide until a second tenant exists; if your dev database always has two, the bug surfaces while you're building, not after a customer reports it.
What I'd undo
- Premature settings. I made things configurable that every tenant left on the default. Each toggle was UI, storage, and a branch in the logic — for nothing. Ship the opinion; add the setting when a customer asks twice.
- Hand-rolled background jobs. My first version of recurring billing was a cron script with home-grown locking. It mostly worked, which is the worst outcome — it failed rarely and silently. A real job runner from the start would have saved a month of mystery.
- Skipping observability. I added structured logging late. Before that, "is it slow for everyone or one gym?" was unanswerable. The first thing I'd install next time is per-tenant timing.
The meta-lesson
Solo, your scarcest resource isn't compute or money — it's the version of you six months from now who has to understand this code. Every decision is really a decision about that person's time. Boring, structural, two-tenants-in-the-seed boring — that's what keeps them sane.
Build the system you can still operate on your worst day, not the one that looks best on a whiteboard.