8 weeks over-engineering a SaaS that has made $0

rust dev.to

At the beginning of July, I launched CDviz Cloud, a hosted version of an open-source CI/CD observability tool I maintain. Three weeks later: zero revenue.

I can't write the growth post — I don't know how to do that part yet. What I can write is the architecture and make the case that the title is only half-serious. It looks like over-engineering because it took eight weeks and has earned nothing. But every choice traces back to a constraint I picked on purpose, and I'd like to lay them out and let you judge.

The problem

CDviz is self-hosted and single-tenant by design. Cloud had to serve many small teams without forking into a second product I'd maintain twice.

A database per tenant

Each team gets a dedicated database — not a schema, not RLS, not a tenant_id column. It's also what Neon, my Postgres provider, recommends, and the reasons are more boring and more convincing than "security":

  • Load isolation. One tenant running an expensive query doesn't degrade everyone else.
  • Deletion is trivial. A GDPR erasure request is a DROP, not an audit of every table for stray rows.
  • Trials and churn clean themselves up. When a trial expires or a subscription ends, the data goes with it — and I stop paying to store rows nobody will ever read.
  • Exit is a real option. If a customer asks for their data, I can transfer ownership of the database itself and let them run it, rather than handing over a dump and wishing them luck with the schema. Alongside the Apache-2.0 self-hosted version, that makes leaving a decision rather than a negotiation.
  • And yes, isolation. The failure mode of a cross-tenant leak in an observability product is "here is your competitor's deployment frequency". I'd rather not be one missing WHERE clause away from it.

Small footprint, and a way out

This runs on k3s, on a VPS I already had. k3s is doing two jobs. It keeps the resource floor low enough for a single machine today, and it means growth is a matter of adding nodes rather than rewriting anything — onto a managed Kubernetes offer, or onto plain VMs at whichever provider is cheapest, or off a provider entirely.

I'll scope that claim honestly, because "portable across clouds" is the most-asserted and least-exercised benefit of Kubernetes: the compute really is portable. The managed Postgres is not. If I ever leave Neon, that's the part that costs me a weekend, and no amount of Kubernetes fixes it.

One Grafana per tenant would have shipped in week two and cost more in idle memory than the product will earn this year. So the webapp renders dashboards itself, querying the tenant database directly.

Running the whole thing locally

There is no single trick for this. I ended up with a different strategy per integration:

  • Email — a local SMTP server (mailcrab), so I can see what a user would have received.
  • Payments / Merchant of Record — a feature flag plus an adapted workflow to skip it entirely in development, and the provider's sandbox for integration tests.
  • Database and supporting services — Docker Compose.
  • Cluster-dependent parts — a real k3s cluster, because mocking Kubernetes teaches you nothing about how Kubernetes behaves.
  • Inbound webhooks — a Cloudflare tunnel, so third-party events reach a laptop that has no public address.

That cost me roughly two weeks up front. In exchange, I can exercise signup → billing → provisioned tenant → dashboard without touching production. Each of these probably deserves its own post; I'll write them.

Two binaries

cdviz-webapp — Dioxus + Axum. Accounts, subscriptions, Merchant of Record, per-tenant database lifecycle, config UI, dashboards. Full-stack Rust means renaming a field on the server breaks the client at compile time instead of in production.

cdviz-operator — kube-rs, one CRD: CDvizTenant. That CRD is the entire interface between the two binaries. The webapp doesn't provision anything; it writes a resource describing the tenant it wants. The operator reconciles toward it: namespace, network policy, Gateway API routes, migrations, a collector instance.

The payoff is that provisioning becomes idempotent by construction. A half-finished tenant isn't corrupted state needing a repair script — it's a resource that hasn't finished reconciling. It retries while I sleep.

Everything is Rust. I like it, that's the honest first reason; the defensible one is a small runtime footprint and a compiler that stands in for the code reviewer I don't have — which matters more now that an agent writes a lot of my first drafts. Playwright and TypeScript for regression tests.

So where's the over-engineering?

Nowhere, is my honest answer — which is a suspicious thing for the author to conclude, so here's the part that could still be wrong.

Every decision above traces back to a constraint: isolation, because deletion and load containment have to be cheap; a small footprint, because a single VPS is what I have; local-first, because the alternative is testing on customers; and a reconciliation loop, because nobody is on call but me. Those are defensible individually.

What's genuinely open is whether they were the right constraints for a product with zero customers.

The operator is the clearest bet. It's the correct design at a few dozen tenants and obvious overkill at five. I built it because a declarative loop that repairs itself overnight is worth more to a solo maintainer than the time it cost — but that's a claim about a future I haven't reached yet. If I'm still at five tenants in a year, it was a hobby, and I'll say so.

That's the honest version. Not regret: an unpaid bet.

The part that isn't architecture

Eight weeks building, three weeks live, zero customers. Engineering is the part I'm good at, so of course that's where the eight weeks went. I told myself the foundation had to be right first. That was true. It was also extremely convenient.

If you've built solo under similar constraints, I'd like to hear which of yours paid off and which are still bets.


CDviz is open source (Apache 2.0) and self-hostable — cdviz.dev.

Source: dev.to

arrow_back Back to Tutorials