multitenancy schema per tenant

multitenancy with schema-per-tenant

how the uptime monitor saas isolates tenant data using postgres schemas instead of a shared table with tenant_id

why schema-per-tenant

three ways to do multitenancy in postgres:

the choice

went with schema-per-tenant. one postgres db, tenant gets a schema like tenant_acmecorp, isolated via SET search_path per request instead of WHERE tenant_id = ? on literally every query ever written.

why not shared-schema

why not database-per-tenant


the two-schema split: core vs tenant_xxx

not everything lives in a tenant schema. split is:

placement test #1 — discover before context

the actual test for "does this table go in core or in a tenant schema":

can this data be queried using something the user already has in hand (their own user_id, an invite code) WITHOUT first knowing which tenant it belongs to? if yes, it has to be core, because the whole point of querying it is often to DISCOVER the tenant.

example: tenant_members

placement test #2 — volume and blast-radius

for things that pass the first test either way (could genuinely go either way structurally), ask: is this high-volume, tenant-exclusive operational data where isolation actually matters for scale/blast-radius?

goes in tenant schema:

stays in core:

concrete split

copy
-- core: identity, tenancy, billing
core.users
core.accounts
core.refresh_tokens
core.tenants
core.tenant_members
core.tenant_invites
core.plans
core.subscriptions
 
-- tenant_acmecorp: per-tenant product data
tenant_acmecorp.monitors
tenant_acmecorp.checks
tenant_acmecorp.alerts

dual-table mirror pattern

one subtlety: some concepts split into TWO tables, one in each schema, because two different consumers ask two different questions about the same event. a monitor going down might write:

you don't build the core mirror for everything, only for the specific things you've decided the platform operator legitimately needs to see. just because data is technically reachable doesn't mean it should be reachable — the platform operator's job is "is the platform healthy", not "let me browse what acme corp is monitoring". design should make the wrong thing structurally awkward, not just policy-forbidden.


tenants table + status-driven provisioning

copy
CREATE TABLE core.tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    slug VARCHAR(63) UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$'),
    name VARCHAR(255) NOT NULL,
    owner_user_id UUID NOT NULL REFERENCES core.users(id),
    status VARCHAR(20) NOT NULL DEFAULT 'pending'
        CHECK (status IN ('pending', 'provisioning', 'active', 'failed')),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE INDEX idx_tenants_owner_user_id ON core.tenants(owner_user_id);

slug rules

regex breakdown:

uniqueness:

reserved words:

status-driven provisioning

status column drives the whole provisioning flow:

  1. user submits onboarding -> INSERT tenants row, status='pending' (always succeeds, just a row insert)
  2. provisioning step runs -> status='provisioning', runs CREATE SCHEMA + migrations inside a transaction
  3. success -> status='active'
  4. failure -> transaction rolls back (no orphaned half-migrated schema), status='failed', error logged

retrying failed provisions:

why varchar not enum

used VARCHAR + CHECK instead of a postgres ENUM here on purpose:


tenant_members: why not just tenant_id on users

a user can belong to zero, one, or many tenants, with a DIFFERENT role in each. if role lived on core.users directly, one column could only ever hold one value — can't represent "alice is owner of acme, member of beta inc" with a single column.

copy
CREATE TABLE core.tenant_members (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
    role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(tenant_id, user_id)
);
 
CREATE INDEX idx_tenant_members_tenant_id ON core.tenant_members(tenant_id);
CREATE INDEX idx_tenant_members_user_id ON core.tenant_members(user_id);

key points:


tenant_invites: separate table, not a column on tenants

first instinct might be "just add an invite_code column to tenants" — wrong, because:

copy
CREATE TABLE core.tenant_invites (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
    code VARCHAR(64) UNIQUE NOT NULL,
    role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')), -- no 'owner' here on purpose
    invited_by UUID NOT NULL REFERENCES core.users(id),
    max_uses INT NOT NULL DEFAULT 1,
    uses_count INT NOT NULL DEFAULT 0,
    expires_at TIMESTAMPTZ NOT NULL,
    revoked BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE INDEX idx_tenant_invites_tenant_id ON core.tenant_invites(tenant_id);

why no owner on invites

invited_by

invited_by is for accountability/audit — who let this person in.

redeeming an invite

copy
-- name: GetInviteByCode :one
SELECT * FROM core.tenant_invites
WHERE code = $1
  AND revoked = false
  AND expires_at > now()
  AND uses_count < max_uses;

note all the validity conditions (not revoked, not expired, not maxed out) are baked into the WHERE clause itself, not checked afterward in app code. "no rows returned" already means "invalid invite" — one less place to forget a check.


subdomain routing — one app, not separate projects per subdomain

the mental model

the mental trap here: it's easy to picture mydomain.com and acmecorp.mydomain.com as two different apps/deployments. they're not. it's the SAME running process, same code, same build. the app just reads the Host header off every incoming request as one more piece of input — same as it reads the url path or query params.

how it works

copy
func TenantResolutionMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        host := c.Request.Host
 
        if host == "internal.mydomain.com" {
            c.Set("route_type", "admin")
            c.Next()
            return
        }
 
        subdomain := extractSubdomain(host)
        if subdomain == "" {
            c.Set("route_type", "root") // mydomain.com itself
            c.Next()
            return
        }
 
        // look up tenant by slug, set search_path
        c.Set("route_type", "tenant")
        c.Set("tenant_slug", subdomain)
        c.Next()
    }
}

local dev

local dev subdomains: *.localhost or *.localtest.me both resolve to 127.0.0.1 with zero /etc/hosts editing. same backend process/port serves every tenant locally too, same as prod.


cookies across subdomains

set cookies with Domain=.mydomain.com (leading dot matters) instead of leaving domain unset.

a cookie scoped this way is sent by the browser on requests to:

one login, one cookie, valid everywhere under the domain. no re-login per subdomain, no session-passing trickery needed.


superadmin panel: role check, not a schema or a membership table

internal.mydomain.com is NOT a tenant, and tenant_members is the wrong table to gate access to it:

the actual mechanism is just a platform-level role column on core.users:

copy
ALTER TABLE core.users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'superadmin'));
copy
func RequireSuperadmin() gin.HandlerFunc {
    return func(c *gin.Context) {
        userID := c.GetString("user_id")
        user, err := queries.GetUserByID(ctx, userID)
        if err != nil || user.Role != "superadmin" {
            c.AbortWithStatus(http.StatusForbidden)
            return
        }
        c.Next()
    }
}

what if a regular user hits it

what happens if a regular authenticated user (valid cookie, role='user') hits internal.mydomain.com:


connection pooling gotcha — the trickiest part of schema-per-tenant

the problem

postgres does NOT auto-reset search_path between requests when using a connection pool:

the fix


queue/worker for the monitoring engine — not one cron job per monitor

wrong vs right pattern

asynq vs rabbitmq

went with asynq (redis-backed, go-native) over rabbitmq:

live results to the dashboard

cron/worker writes result -> publishes to redis pub/sub -> websocket server pushes to connected clients.

pub/sub specifically matters for horizontal scaling — if you have multiple server instances, pub/sub is how a result written by one instance's worker reaches a websocket connection sitting on a different instance.