Building a multi-tenant VPS platform: the architecture

How we designed data isolation, per-tenant branding, and cross-surface auth for Jooule DCIM — lessons from eighteen months of building SaaS for hosting companies.

Bernhard
Multi-tenant architecture diagram

Multi-tenancy is one of those problems that looks simple in the demo and eats you alive in production. This is how we approached it for Jooule DCIM — and why we’d make the same calls again.

The three-surface problem

Jooule serves three kinds of users:

  • SystemUser — platform operators (us)
  • CompanyAdmin — hosting company staff managing their tenant
  • CompanyClient — end users buying VPS from a hosting company

These can’t share an auth system naively. A CompanyClient of Acme Hosting should never be able to log into Beta Hosting with the same credentials, even if their email matches. Each tenant is a sealed box.

Early on we tried a single User model with a company_id foreign key. It worked until the second edge case: OAuth. If Bob uses “Sign in with GitHub” on acme.dcim.sc, and the same GitHub account on beta.dcim.sc, what happens? Merging the accounts leaks data across tenants. Blocking the second login confuses users. Neither is right.

So we split. Three separate models: SystemUser, CompanyAdmin, CompanyClient. Each with its own auth table, its own 2FA, its own OAuth linkage. They never mix. A single AuthToken table with a GenericForeignKey points to whichever user type issued it.

Lesson: if your users have different trust boundaries, give them different tables. The JOIN cost is worth it.

Hostname-based routing

A tenant’s storefront lives at a subdomain or custom domain. The first byte of the request has to decide which tenant this is.

Our middleware (Next.js edge runtime for storefront, Django middleware for the API) does the lookup: subdomain → tenant ID → inject headers. Everything downstream reads the tenant ID from headers, never from the URL directly.

# Backend: every GraphQL resolver tenant-scopes implicitly
class CompanyClientQuerySet(models.QuerySet):
    def for_request(self, request):
        return self.filter(company_id=request.tenant_id)

This is the single most valuable piece of infrastructure we built. It makes leaking across tenants physically hard — a developer would have to deliberately bypass the scoping.

Lesson: enforce tenant isolation at the ORM level, not the view level. Defense in depth matters when “shipping fast” is your other priority.

Per-tenant branding

Each tenant has a logo, color palette, tagline, TOS HTML, support email. The storefront app reads these at request time (cached 5 min in middleware) and injects them as CSS variables:

--brand-primary: #0052cc;
--brand-primary-hover: #003d99;
--brand-primary-light: #e6f2ff;

Components reference var(--brand-primary) — there’s no tenant-specific code anywhere in the component tree. The same build serves every tenant.

For the TOS and privacy pages (tenant-authored HTML), we sanitize with DOMPurify before render. Every tenant-provided string is treated as hostile until proven otherwise.

Impersonation without chaos

Platform admins need to “log in as a client” to debug issues. This is always a security footgun.

Our approach: admin clicks “Impersonate” → backend mints a short-lived (8h) access token bound to the client’s account, with an audit log entry. The admin is redirected to the tenant storefront with ?impersonate=<token> in the URL. The storefront’s middleware accepts this as an alternative to a session cookie — but only because the backend will reject the token on any API call if it’s invalid or expired.

The admin user identity is preserved in the audit log: we record who impersonated whom, when, and for how long. No admin action ever attributes to the client account.

Lesson: “skip the auth check” is never the answer. “Trust the backend to validate the token” is.

What we’d change

One call we’re reconsidering: we kept billing in Postgres instead of pushing it to Stripe’s ledger. This made multi-gateway easier (supporting multiple gateways over time) but means we write a lot of billing code that Stripe would write for us. The tradeoff is worth it for a hosting-specific billing model (bandwidth overages, IP address pricing, suspension grace periods) that doesn’t fit Stripe’s subscription primitives cleanly — but it’s a tradeoff, not a clear win.


If you’re building something similar and want to compare notes, email us. We read everything.