# Build Multi-Tenant SaaS Apps | Volcano

URL: https://volcano.dev/solutions/saas-apps

> Multi-tenant SaaS on Volcano: tenant isolation enforced by Postgres row-level security, managed auth with OAuth and anonymous trials, Next.js hosting with custom domains, and one YAML manifest per environment.

## 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.

```console
$ volcano init nextjs
$ volcano cloud databases migration up --all -d app
001_tenants.sql   memberships · policies on every tenant table
$ volcano cloud config deploy --dry-run
auth.providers.oauth  google (+), github (+)
frontends[web]        custom_domain app.acme.com
$ volcano cloud config deploy && volcano cloud frontends deploy --name web
✓ config applied · Frontend 'web' deployment started
```

## 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.

## The tenant filter you cannot forget

*Isolation*

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

Image: The accounts whose identity policies are evaluated against.

## Let them try before they sign up

*Activation*

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

```console
$ auth.signInAnonymously({ plan: 'trial' })
user 8f1c4b0e-…  role anonymous
# they create three projects, then decide to stay
$ auth.convertAnonymous({ email, password })
user 8f1c4b0e-…  role authenticated
same id · the three projects are still theirs
```

## Configuration you can review

*Environments*

Auth settings, variables, buckets, policies, schedulers and domains live in volcano-config.yaml. Pull it from production, apply it to a staging project, 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

```console
variables        4 update · 1 delete
auth.oauth       google update · github create
buckets[avatars] policies 2 create
frontends[web]   custom_domain unchanged
Dry run: projected actions, nothing was applied.
```

## How it works

1. **Model tenants as data**: A tenants table, a memberships table, and a tenant\_id on everything a customer owns.
2. **Write the policies**: Read through membership, write through role. Every client and job you add later inherits the boundary.
3. **Turn on sign-in**: Email and password, OAuth, or anonymous trials that convert. Declare it so staging matches production.
4. **Ship the app**: Deploy the Next.js frontend, then attach your own domain and certificate to it.

## Tenancy, onboarding and environments

### Tenancy

```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
);

create table if not exists invites (
  tenant_id  uuid not null references tenants(id) on delete cascade,
  email      text not null,
  role       text not null check (role in ('admin', 'member')),
  invited_by uuid not null,
  primary key (tenant_id, email)
);

alter table tenants     enable row level security;
alter table memberships enable row level security;
alter table projects    enable row level security;
alter table invites     enable row level security;

-- Members see their own tenants. Nobody changes a plan from the browser.
drop policy if exists member_reads_tenant on tenants;
create policy member_reads_tenant on tenants
  for select to authenticated
  using (exists (
    select 1 from memberships m
    where m.tenant_id = tenants.id and m.user_id = auth.uid()
  ));

drop policy if exists see_own_memberships on memberships;
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.
drop policy if exists tenant_read on projects;
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()
  ));

drop policy if exists tenant_admin_write on projects;
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')
  ));

-- Only a tenant's owners and admins can invite.
drop policy if exists tenant_admin_invites on invites;
create policy tenant_admin_invites on invites
  for insert to authenticated
  with check (exists (
    select 1 from memberships m
    where m.tenant_id = invites.tenant_id
      and m.user_id = auth.uid()
      and m.role in ('owner', 'admin')
  ));
```

### Onboarding

```typescript
import { VolcanoAuth } from '@volcano.dev/sdk';

const volcano = new VolcanoAuth({
  apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,
  anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,
});
volcano.database('app');

// 1. A visitor starts a trial with no form to fill in.
await volcano.auth.signInAnonymously({ display_name: 'Guest' });

// A new user belongs to no tenant yet, so a function holding the service key
// creates the tenant and the owner membership together. It is authenticated,
// which lets anonymous sign-ins in too.
await volcano.functions.invoke('create-workspace', { name: 'My first workspace' });

// 2. Later they create a real account. The user id is preserved, so the
//    rows they already own stay theirs.
const { user, error } = await volcano.auth.convertAnonymous({
  email: 'ada@acme.com',
  password: strongPassword,
  metadata: { full_name: 'Ada Lovelace' },
});

// 3. Or bring an identity provider instead.
volcano.auth.signInWithOAuth('google');
```

### Invites

```javascript
const { Client } = require('pg');
const { databaseConnectionString } = require('@volcano.dev/sdk');

exports.handler = async (event) => {
  const auth = event.__volcano_auth;
  if (!auth || auth.role === 'anonymous') {
    return { statusCode: 401, body: JSON.stringify({ error: 'sign in required' }) };
  }

  const { tenantId, email, role } = event;

  // Connect as the caller. The invites policy lets only the tenant's owners
  // and admins insert, so the check cannot be forgotten here.
  const db = new Client({
    connectionString: databaseConnectionString(process.env.DATABASE_URL, {
      userId: auth.user_id,
    }),
  });
  await db.connect();
  try {
    await db.query(
      'insert into invites (tenant_id, email, role, invited_by) values ($1, $2, $3, $4)',
      [tenantId, email, role, auth.user_id],
    );
  } catch (err) {
    // 42501 is Postgres refusing the row under the policy: an authorization
    // failure, not a 500.
    if (err.code === '42501') {
      return { statusCode: 403, body: JSON.stringify({ error: 'not an admin of this tenant' }) };
    }
    throw err;
  } finally {
    await db.end();
  }

  return { statusCode: 202, body: JSON.stringify({ invited: email }) };
};
```

### Environments

```bash
# Capture what production actually looks like
volcano use acme-prod
volcano cloud config pull --force

# The export carries production's project name, auth redirects and domains.
# Point those at staging, or interpolate them from the environment, first.
volcano use acme-staging
volcano cloud config deploy --dry-run     # prints every projected change
volcano cloud config deploy

# Promote code the same way
volcano cloud functions deploy --all
volcano cloud frontends deploy --name web --path ./web

# Projects are isolated: staging keys never work against production.
```

## What tenancy in the database saves

- **One boundary for every code path**: New endpoints, jobs and clients inherit isolation instead of re-implementing it.
- **4 providers of managed sign-in**: Google, GitHub, Microsoft and Apple beside email and password, with refresh handled.
- **Two commands to reproduce an environment**: config pull and config deploy make a staging project a copy of production's configuration.

## Outcomes

- **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.

## Built on Volcano

- [Row-level security](https://volcano.dev/features/postgres): Policies join through your membership table, so the access rule sits next to the rows it protects.
- [Authentication](https://volcano.dev/features/authentication): Email and password, four OAuth providers, anonymous trials, and sessions you can revoke one device at a time.
- [Next.js hosting](https://volcano.dev/features/nextjs-hosting): Your app on a global CDN with TLS, plus custom domains on every frontend.
- [Privileged actions](https://volcano.dev/features/edge-functions): Invites, exports and billing hooks run as functions that see the caller's verified identity.

## Frequently asked questions

### 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?

Yes. A frontend takes as many custom domains as your plan allows, each with a certificate Volcano issues or one you supply. Most products serve every tenant from one hostname; give a customer their own domain on the same frontend, or their own frontend if they need separate builds.

### 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 volcano cloud config deploy --dry-run shows exactly what applying it elsewhere would change. Point the project name and auth redirects at the target environment before you apply it.

### How do I run onboarding and trial sequences?

As a durable function per signup. Send the welcome email, wait three days, check whether they created a project, nudge them if not, and wait again until the trial ends. Each wait holds nothing running, and a crash resumes at the next step without sending an email twice.

## Get started

Ready to build your SaaS? Create a free account at https://volcano.dev/signup, or have your coding agent follow the setup playbook at https://volcano.dev/setup.md.
