Multi-Tenant SaaS Architecture Patterns
Build secure, isolated multi-tenant applications with tenant-specific data, credentials, and configurations.
Tenant Isolation Strategies
Schema-per-Tenant (PostgreSQL)
Isolate tenant data using PostgreSQL schemas with row-level security.
#r "../src/bin/Release/net8.0/publish/Amazon.JSII.Runtime.dll"
#r "../src/bin/Release/net8.0/publish/Constructs.dll"
#r "../src/bin/Release/net8.0/publish/Amazon.CDK.Lib.dll"
#r "../src/bin/Release/net8.0/publish/FsCDK.dll"
open FsCDK
open Amazon.CDK
open Amazon.CDK.AWS.RDS
open Amazon.CDK.AWS.EC2
open Amazon.CDK.AWS.DynamoDB
open Amazon.CDK.AWS.SecretsManager
Tenant Registry Pattern
Use DynamoDB to track tenant metadata, status, and configuration.
stack "TenantRegistry" {
// Central tenant registry
table "tenant-registry" {
partitionKey "tenantId" AttributeType.STRING
sortKey "environment" AttributeType.STRING
billingMode BillingMode.PAY_PER_REQUEST
pointInTimeRecovery true
// Global Secondary Index for querying by status
globalSecondaryIndex "status-index" {
partitionKey "status" AttributeType.STRING
sortKey "createdAt" AttributeType.STRING
}
}
// Tenant configuration data
table "tenant-config" {
partitionKey "tenantId" AttributeType.STRING
sortKey "configKey" AttributeType.STRING
billingMode BillingMode.PAY_PER_REQUEST
stream StreamViewType.NEW_AND_OLD_IMAGES
}
}
Per-Tenant Secrets
Store tenant-specific API keys, credentials, and configuration in Secrets Manager.
stack "TenantSecrets" {
// Template for tenant secrets (create per tenant programmatically)
secret "tenant-template-config" {
description "Template for tenant-specific configuration"
// Store tenant-specific secrets like:
// - API keys for third-party services
// - Database credentials
// - Encryption keys
generateSecretString (
SecretsManagerHelpers.generateJsonSecret """{"apiEndpoint": "https://api.example.com"}""" "apiKey"
)
}
}
Multi-Tenant Database Architecture
Option 1: Schema-per-Tenant (Single Database)
Best for: 100-1000 tenants, simplified operations, cost efficiency
stack "SchemaPerTenant" {
let! appVpc = vpc "AppVPC" { maxAzs 2 }
rdsInstance "SharedDatabase" {
vpc appVpc
postgresEngine PostgresEngineVersion.VER_15
instanceType (InstanceType.Of(InstanceClass.MEMORY5, InstanceSize.LARGE))
databaseName "multitenant"
// Large instance for many tenants
allocatedStorage 100
multiAz true
// Enable IAM authentication for application access
iamAuthentication true
// Strong encryption for tenant data
storageEncrypted true
deletionProtection true
}
}
PostgreSQL Schema Isolation Pattern
|
Option 2: Database-per-Tenant (Large Tenants)
Best for: <100 tenants, enterprise customers, regulatory isolation
stack "DatabasePerTenant" {
let! appVpc = vpc "AppVPC" { maxAzs 2 }
// Template - deploy per large tenant
rdsInstance "EnterpriseCustomerDB" {
vpc appVpc
postgresEngine PostgresEngineVersion.VER_15
instanceType (InstanceType.Of(InstanceClass.MEMORY5, InstanceSize.XLARGE))
databaseName "enterprise_tenant"
multiAz true
backupRetentionDays 30.0
storageEncrypted true
deletionProtection true
enablePerformanceInsights true
}
}
Tenant Context Middleware Pattern
Pass tenant context through request headers for isolation.
// API Gateway passes tenant ID from authorizer
let tenantId = request.Headers.["X-Tenant-ID"]
let tenantRole = request.Headers.["X-Tenant-Role"]
// Set PostgreSQL session variable for RLS
Database.executeRaw $"SET app.current_tenant_id = '{tenantId}'"
// All queries now isolated to tenant
let users = Users.getAll() // Only returns tenant's users
Cost Optimization by Tenant Tier
Adjust infrastructure based on tenant size/importance:
Tier |
Database |
Backup |
Instance |
Monthly Cost |
|---|---|---|---|---|
Free |
Shared schema |
1 day |
Shared |
~$0 |
Standard |
Shared schema |
7 days |
Shared |
~$5/tenant |
Premium |
Shared schema |
30 days |
Shared |
~$15/tenant |
Enterprise |
Dedicated DB |
30 days |
Dedicated |
~$500+/tenant |
Security Best Practices
Tenant Isolation Checklist
- [x] Database row-level security (RLS) enforced
- [x] Application validates tenant ID on every request
- [x] Secrets isolated per tenant in Secrets Manager
- [x] CloudWatch logs include tenant context
- [x] IAM policies scoped to tenant resources
- [x] Audit trail includes tenant ID
- [x] Cross-tenant queries blocked at database level
Prevent Cross-Tenant Data Leaks
// ❌ BAD: No tenant filtering
let user = Users.getById(userId)
// ✅ GOOD: Always include tenant context
let user = Users.getById(userId, tenantId)
// ✅ BETTER: Tenant context in session
Database.setTenantContext(tenantId)
let user = Users.getById(userId) // RLS enforces isolation
Tenant Provisioning Workflow
- Create tenant record in DynamoDB registry
- Generate secrets in Secrets Manager
- Provision database schema (if schema-per-tenant)
- Create IAM users/roles (if dedicated resources)
- Initialize default data for new tenant
- Mark tenant as active in registry
Monitoring Multi-Tenant Systems
Tag CloudWatch metrics with tenant ID for per-tenant observability in your application code (not IaC). Use AWS SDK CloudWatch client to emit metrics with tenant dimensions.
Resources
- AWS Multi-Tenant SaaS Architecture
- PostgreSQL Row Level Security
- AWS SaaS Factory
- Multi-Tenant Database Patterns
<summary>Creates an AWS CDK Stack construct.</summary>
<param name="name">The name of the stack.</param>
<code lang="fsharp"> stack "MyStack" { lambda myFunction bucket myBucket } </code>
<summary>Creates a DynamoDB table configuration.</summary>
<param name="name">The table name.</param>
<code lang="fsharp"> table "MyTable" { partitionKey "id" AttributeType.STRING billingMode BillingMode.PAY_PER_REQUEST } </code>
<summary>Sets the partition key for the table.</summary>
<param name="config">The current table configuration.</param>
<param name="name">The attribute name for the partition key.</param>
<param name="attrType">The attribute type (STRING, NUMBER, or BINARY).</param>
<code lang="fsharp"> table "MyTable" { partitionKey "id" AttributeType.STRING } </code>
<summary>Sets the sort key for the table.</summary>
<param name="config">The current table configuration.</param>
<param name="name">The attribute name for the sort key.</param>
<param name="attrType">The attribute type (STRING, NUMBER, or BINARY).</param>
<code lang="fsharp"> table "MyTable" { partitionKey "userId" AttributeType.STRING sortKey "timestamp" AttributeType.NUMBER } </code>
<summary>Sets the billing mode for the table.</summary>
<param name="config">The current table configuration.</param>
<param name="mode">The billing mode (PAY_PER_REQUEST or PROVISIONED).</param>
<code lang="fsharp"> table "MyTable" { billingMode BillingMode.PAY_PER_REQUEST } </code>
<summary>Enables or disables point-in-time recovery.</summary>
<param name="config">The current table configuration.</param>
<param name="enabled">Whether point-in-time recovery is enabled.</param>
<code lang="fsharp"> table "MyTable" { pointInTimeRecovery true } </code>
<summary>Creates global secondary indexes for a DynamoDB table.</summary>
<param name="name">The index name.</param>
<code lang="fsharp"> globalSecondaryIndex "my-index" { partitionKey "gsiPk" AttributeType.STRING sortKey "gsiSk" AttributeType.NUMBER projectionType ProjectionType.ALL } </code>
<summary>Sets the partition key for the Global Secondary Index (GSI).</summary>
<param name="config">The current global secondary index configuration.</param>
<param name="attrName">The attribute name for the partition key.</param>
<param name="attrType">The attribute type (STRING, NUMBER, or BINARY).</param>
<code lang="fsharp"> globalSecondaryIndex "my-index" { partitionKey "gsiPk" AttributeType.STRING } </code>
<summary>Sets the sort key for the Global Secondary Index (GSI).</summary>
<param name="config">The current global secondary index configuration.</param>
<param name="attrName">The attribute name for the sort key.</param>
<param name="attrType">The attribute type (STRING, NUMBER, or BINARY).</param>
<code lang="fsharp"> globalSecondaryIndex "my-index" { sortKey "gsiSk" AttributeType.NUMBER } </code>
<summary>Enables DynamoDB Streams for the table.</summary>
<param name="config">The current table configuration.</param>
<param name="streamType">The stream view type (KEYS_ONLY, NEW_IMAGE, OLD_IMAGE, or NEW_AND_OLD_IMAGES).</param>
<code lang="fsharp"> table "MyTable" { stream StreamViewType.NEW_AND_OLD_IMAGES } </code>
<summary> Creates a new Secrets Manager secret builder with secure defaults. Example: secret "my-api-key" { description "API key for external service" } </summary>
<summary> Helper functions for creating secret string generators </summary>
<summary> Creates a secret string generator for JSON secrets (e.g., database credentials) </summary>
<summary>Creates a VPC configuration with AWS best practices.</summary>
<param name="name">The VPC name.</param>
<code lang="fsharp"> vpc "MyVpc" { maxAzs 2 natGateways 1 cidr "10.0.0.0/16" } </code>
<summary>Sets the maximum number of Availability Zones to use.</summary>
<param name="config">The current VPC configuration.</param>
<param name="maxAzs">The maximum number of AZs (default: 2 for HA).</param>
<code lang="fsharp"> vpc "MyVpc" { maxAzs 3 } </code>
<summary>Creates an RDS Database Instance with AWS best practices.</summary>
<param name="name">The database instance name.</param>
<code lang="fsharp"> rdsInstance "MyDatabase" { vpc myVpc postgresEngine PostgresEngineVersion.VER_15 instanceType (InstanceType.Of(InstanceClass.BURSTABLE3, InstanceSize.SMALL)) multiAz true backupRetentionDays 7.0 } </code>
<summary>Sets the VPC.</summary>
<summary>Sets PostgreSQL as the database engine with a specific version.</summary>
<summary>Sets the instance type.</summary>
type InstanceType = inherit DeputyBase new: instanceTypeIdentifier: string -> unit member IsBurstable: unit -> bool member SameInstanceClassAs: other: InstanceType -> bool member ToString: unit -> string static member Of: instanceClass: InstanceClass * instanceSize: InstanceSize -> InstanceType member Architecture: InstanceArchitecture
--------------------
InstanceType(instanceTypeIdentifier: string) : InstanceType
<summary>Sets the database name.</summary>
<summary>Sets the allocated storage in GB.</summary>
<summary>Enables or disables Multi-AZ deployment.</summary>
<summary>Enables IAM authentication.</summary>
<summary>Enables storage encryption.</summary>
<summary>Enables or disables deletion protection.</summary>
<summary>Sets the backup retention period in days.</summary>
<summary>Enables performance insights.</summary>
FsCDK