Source:
ocean/docs/adr/015-regional-tenant-provisioning.md| ✏️ Edit on GitHub
ADR-015: Regional Tenant Provisioning Architecture
Status
Accepted
Date
2025-08-12
Context
Our SaaS platform needs to provision isolated resources for each customer organization including:
- Stripe customer accounts for billing
- Neon database instances for data isolation
- Regional deployment based on user preferences for compliance and performance
The provisioning must be:
- Asynchronous to avoid blocking user signup
- Resilient to failures with retry capabilities
- Region-aware for data residency requirements
- Secure with proper secret management
Decision
We implement a parallel asynchronous provisioning system triggered during user signup that:
- Captures regional preference during signup via the
dataRegionfield - Provisions resources in parallel using Promise.allSettled() for resilience
- Uses Edge Functions for serverless execution with automatic scaling
- Stores secrets securely in Supabase secrets for Edge Function access
Architecture Components
Implementation Details
-
Signup Flow (
/supabase/functions/graphql-v2/resolvers/base.ts)signUp: async (args) => {
// 1. Create user with regional preference
const user = await supabase.auth.signUp({
email: input.email,
options: {
data: {
hosting_region: input.dataRegion, // Captured preference
},
},
})
// 2. Organization created by database trigger
// 3. Async provisioning (fire and forget)
provisionResources(org.id, user, supabase).catch((err) => {
logger.error('Provisioning error:', err)
// Log but don't fail signup
})
} -
Provisioning Service (
/supabase/functions/graphql-v2/services/provisioning.ts)export async function provisionResources(orgId, user, supabase) {
await Promise.allSettled([
// Stripe Customer
createStripeCustomer(orgId, user),
// Neon Database with regional mapping
createNeonProject({
name: `org-${orgId}`,
region: mapDataRegionToNeon(user.user_metadata.hosting_region),
}),
])
} -
Regional Mapping
function mapDataRegionToNeon(region: string): string {
const regionMap = {
'us-east': 'aws-us-east-1',
'us-west': 'aws-us-west-2',
'eu-central': 'aws-eu-central-1',
'eu-west': 'aws-eu-west-1',
'ap-southeast': 'aws-ap-southeast-1',
'ap-northeast': 'aws-ap-northeast-1',
}
return regionMap[region] || 'aws-us-east-2' // Default
} -
Secret Management
- API keys stored in Supabase secrets (not environment variables)
- Edge Functions access via
Deno.env.get() - Deployment workflow sets secrets automatically
- Connection strings stored in Supabase vault
Database Schema
-- Organizations table tracks provisioning status
ALTER TABLE organizations ADD COLUMN stripe_customer_id TEXT;
ALTER TABLE organizations ADD COLUMN neon_tenant_id TEXT;
ALTER TABLE organizations ADD COLUMN neon_database_host TEXT;
ALTER TABLE organizations ADD COLUMN neon_database_ready BOOLEAN DEFAULT false;
-- Provisioning events for monitoring
CREATE TABLE provisioning_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id),
event_type TEXT NOT NULL,
status TEXT NOT NULL,
metadata JSONB,
error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
Error Handling & Resilience
- Parallel Execution: Uses
Promise.allSettled()so one failure doesn't block others - Validation & Retry:
validateOrganizationReady()checks and retries missing resources - Logging: Comprehensive logging with Sentry integration for monitoring
- Manual Intervention: Failed provisioning logged for manual resolution
Consequences
Positive
- Immediate user access: Users can log in while provisioning happens in background
- Regional compliance: Data stored in user's preferred region
- Fault tolerance: Individual service failures don't block entire provisioning
- Scalable: Serverless Edge Functions handle any load
- Secure: Secrets properly managed, never exposed in code
Negative
- Eventual consistency: Resources may not be immediately available
- Complexity: Distributed system with multiple failure points
- Cost: Running multiple services (Stripe, Neon) per tenant
- Debugging: Async operations harder to troubleshoot
Trade-offs
- Chose async provisioning over sync for better UX despite complexity
- Parallel execution over sequential for speed despite harder error handling
- Regional deployment over single region for compliance despite cost
Implementation Checklist
- GraphQL signup mutation with regional preference
- Async provisioning service
- Stripe customer creation
- Neon database provisioning with regions
- Secret management in Supabase
- Deployment workflow updates
- Monitoring dashboard for provisioning status
- Automated retry mechanism for failures
- Admin tools for manual provisioning