how the uptime monitor saas isolates tenant data using postgres schemas instead of a shared table with tenant_id
three ways to do multitenancy in postgres:
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.
not everything lives in a tenant schema. split is:
core schema — platform-wide stuff. identity, tenancy metadata, billing. queried before you even know which tenant you're dealing with, or queried across all tenants (superadmin stuff).tenant_acmecorp schema (one per tenant) — that tenant's actual product data. monitors, checks, alerts. only ever queried once you're already inside that tenant's 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
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:
-- 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.alertsone 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:
tenant_acmecorp.notifications — "your monitor is down", for that tenant's own teamcore.platform_events — "tenant acme had a monitor go down", only if you actually want superadmin cross-tenant visibility into that event typeyou 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.
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);regex breakdown:
-acme.domain.com or acme-.domain.com would be a broken subdomainuniqueness:
reserved words:
status column drives the whole provisioning flow:
retrying failed provisions:
used VARCHAR + CHECK instead of a postgres ENUM here on purpose:
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.
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:
UNIQUE(tenant_id, user_id) is what actually enforces one role per person per tenant — without it you could get duplicate/conflicting membership rowsfirst instinct might be "just add an invite_code column to tenants" — wrong, because:
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);invited_by is for accountability/audit — who let this person in.
-- 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.
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.
*.mydomain.com -> your app's IP) makes every possible subdomain land on the same serveracmecorp. "feel like" a different space than betainc. — it's the same code checking a Host header and deciding what to query, not different code runningfunc 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 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.
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:
mydomain.comacmecorp.mydomain.cominternal.mydomain.comone login, one cookie, valid everywhere under the domain. no re-login per subdomain, no session-passing trickery needed.
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:
ALTER TABLE core.users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'superadmin'));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 happens if a regular authenticated user (valid cookie, role='user') hits internal.mydomain.com:
postgres does NOT auto-reset search_path between requests when using a connection pool:
SET search_path = tenant_acme on a pooled connection and then release it back to the pool without resetting, the NEXT request that borrows that same connection could silently inherit the wrong tenant's search_pathwent with asynq (redis-backed, go-native) over rabbitmq:
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.