SaaS apps

Multi-tenant SaaS, isolated by the database

Tenant isolation lives in Postgres row-level security, so a query that forgets its tenant returns nothing instead of everything.

one-tenant-boundary
volcano init nextjsvolcano cloud databases migration up --all -d app001_tenants.sql   memberships · policies on every tenant tablevolcano cloud config deploy --dry-runauth.providers.oauth  google (+), github (+)frontends[web]        custom_domain app.acme.comvolcano cloud config deploy && volcano cloud frontends deploy --name web✓ live on app.acme.com · sign-in working · tenants isolated
Overview

Every multi-tenant bug is the same bug

A query went out without its tenant filter. The usual defence is a WHERE clause in every repository method and a reviewer who catches the one that is missing, which holds until the team grows or someone adds a background job in a hurry.

Put the boundary under the code instead. Row-level policies in Postgres are evaluated against auth.uid() and your own membership table, and Volcano fixes who the caller is when the connection opens. The browser, a function, a cron job and a realtime subscription all inherit the same rule.

Isolation

The tenant filter you cannot forget

With row-level security a query missing its tenant filter returns nothing rather than everything. Postgres applies the rule to every query that reaches the table, whether it came from your app, a function, the SDK in a browser or a psql session.

  • One policy per table, through your membership model
  • Role checks live beside the read rule
  • Service keys skip policies, so keep them on a server
volcano.dev/dashboard/your-users
The accounts whose identity policies are evaluated against
Activation

Let them try before they sign up

An anonymous session gives a visitor a real user id and real rows, protected by the same policies as a paying customer. Converting to a permanent account keeps the id, so the work they already did stays theirs.

  • Anonymous users convert without losing data
  • Google, GitHub, Microsoft and Apple sign-in
  • Hosted login pages, restyled or replaced with your own
anonymous → permanent
auth.signUpAnonymous({ plan: 'trial' })user 8f1c4b0e-…  role anonymous# they create three projects, then decide to stayauth.convertAnonymous({ email, password })user 8f1c4b0e-…  role authenticatedsame id · the three projects are still theirs
Environments

Configuration you can review

Auth settings, variables, buckets, policies, schedulers and domains live in volcano-config.yaml. Pull it from production, apply it to staging, and dry-run first to see exactly what would change.

  • --dry-run prints projected actions, changes nothing
  • Declared sections are synced, so drift is removed
  • Secrets interpolate from the environment, never the file
volcano cloud config deploy --dry-run
variables        4 update · 1 deleteauth.oauth       google update · github createbuckets[avatars] policies 2 createfrontends[web]   custom_domain unchangedDry run: projected actions, nothing was applied.
Path to production

How it works

  1. 01

    Model tenants as data

    A tenants table, a memberships table, and a tenant_id on everything a customer owns.

  2. 02

    Write the policies

    Read through membership, write through role. Every client and job you add later inherits the boundary.

  3. 03

    Turn on sign-in

    Email and password, OAuth, or anonymous trials that convert. Declare it so staging matches production.

  4. 04

    Ship the app

    Deploy the Next.js frontend, then attach your own domain and certificate to it.

Code

Tenancy, onboarding and environments

SQL
create table if not exists tenants ( id uuid primary key default gen_random_uuid(), name text not null, plan text not null default 'trial' ); create table if not exists memberships ( tenant_id uuid not null references tenants(id) on delete cascade, user_id uuid not null, role text not null check (role in ('owner', 'admin', 'member')), primary key (tenant_id, user_id) ); create table if not exists projects ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references tenants(id) on delete cascade, name text not null ); alter table memberships enable row level security; alter table projects enable row level security; create policy see_own_memberships on memberships for select to authenticated using (user_id = auth.uid()); -- Read if you belong to the tenant. Write only if you are an admin of it. create policy tenant_read on projects for select to authenticated using (exists ( select 1 from memberships m where m.tenant_id = projects.tenant_id and m.user_id = auth.uid() )); create policy tenant_admin_write on projects for all to authenticated using (exists ( select 1 from memberships m where m.tenant_id = projects.tenant_id and m.user_id = auth.uid() and m.role in ('owner', 'admin') ));
Return

What tenancy in the database saves

One boundaryfor every code path

New endpoints, jobs and clients inherit isolation instead of re-implementing it.

4 providersof managed sign-in

Google, GitHub, Microsoft and Apple beside email and password, with refresh handled.

Two commandsto reproduce an environment

config pull and config deploy make staging a copy of production's configuration.

What you get

Safe by default

A forgotten tenant filter returns nothing, not everything.

Faster onboarding

Anonymous trials convert to accounts without losing data.

No drift

Environments are one manifest applied to isolated projects.

Platform

Built on Volcano

Databases and vector
  • Row-level security

    Policies join through your membership table, so the access rule sits next to the rows it protects.

Authentication
  • Authentication

    Email and password, four OAuth providers, anonymous trials, and sessions you can revoke one device at a time.

Next.js frontends
  • Next.js hosting

    Your app on a global CDN with TLS, plus one custom domain per frontend.

Functions and agents
  • Privileged actions

    Invites, exports and billing hooks run as functions that see the caller's verified identity.

Frequently asked questions

Read the docs
One database with row-level security, or a database per tenant?

Start with one database and row-level security, which keeps queries and migrations simple while making isolation unforgeable. When a customer needs hard separation, give them their own database or their own project.

How do I enforce roles like owner, admin and member?

Put the role on the membership row and reference it in the policy. A read policy checks membership; a write policy also requires the role to be owner or admin.

Can each customer have their own domain?

A frontend takes one custom domain, with a certificate you supply. Most products serve every tenant from one hostname, but you can deploy a frontend per customer if you need to.

How do trials work?

Enable anonymous sign-ins and a visitor gets a real session immediately, with their rows protected by the same policies as anyone else. Converting keeps the user id, so nothing they made is orphaned.

How do I keep staging and production in sync?

One project per environment and one manifest. volcano cloud config pull exports the current configuration and --dry-run shows exactly what applying it elsewhere would change.

What stops a service key leaking into the browser?

Nothing technical, so treat it like any other server secret. The anon key is the one meant for the browser; the service key bypasses row-level security and belongs in project variables read by functions.

Ready to build your SaaS?

Build, deploy, and scale on Volcano's global platform — free to start, with no infrastructure to manage.

Explore more solutions