Header menu logo FsCDK

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

-- Create tenant-specific schema
CREATE SCHEMA tenant_acme;

-- Row-level security policy
ALTER TABLE tenant_acme.users ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON tenant_acme.users
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- Grant application user access
GRANT USAGE ON SCHEMA tenant_acme TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tenant_acme TO app_user;

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

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

  1. Create tenant record in DynamoDB registry
  2. Generate secrets in Secrets Manager
  3. Provision database schema (if schema-per-tenant)
  4. Create IAM users/roles (if dedicated resources)
  5. Initialize default data for new tenant
  6. 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

namespace FsCDK
namespace Amazon
namespace Amazon.CDK
namespace Amazon.CDK.AWS
namespace Amazon.CDK.AWS.RDS
namespace Amazon.CDK.AWS.EC2
namespace Amazon.CDK.AWS.DynamoDB
namespace Amazon.CDK.AWS.SecretsManager
val stack: name: string -> StackBuilder
<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>
val table: name: string -> TableBuilder
<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>
custom operation: partitionKey (string) (AttributeType) Calls TableBuilder.PartitionKey
<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>
[<Struct>] type AttributeType = | BINARY = 0 | NUMBER = 1 | STRING = 2
field AttributeType.STRING: AttributeType = 2
custom operation: sortKey (string) (AttributeType) Calls TableBuilder.SortKey
<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>
custom operation: billingMode (BillingMode) Calls TableBuilder.BillingMode
<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>
[<Struct>] type BillingMode = | PAY_PER_REQUEST = 0 | PROVISIONED = 1
field BillingMode.PAY_PER_REQUEST: BillingMode = 0
custom operation: pointInTimeRecovery (bool) Calls TableBuilder.PointInTimeRecovery
<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>
val globalSecondaryIndex: name: string -> GlobalSecondaryIndexBuilder
<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>
custom operation: partitionKey (string) (AttributeType) Calls GlobalSecondaryIndexBuilder.PartitionKey
<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>
custom operation: sortKey (string) (AttributeType) Calls GlobalSecondaryIndexBuilder.SortKey
<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>
custom operation: stream (StreamViewType) Calls TableBuilder.Stream
<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>
[<Struct>] type StreamViewType = | NEW_IMAGE = 0 | OLD_IMAGE = 1 | NEW_AND_OLD_IMAGES = 2 | KEYS_ONLY = 3
field StreamViewType.NEW_AND_OLD_IMAGES: StreamViewType = 2
val secret: name: string -> SecretsManagerBuilder
<summary> Creates a new Secrets Manager secret builder with secure defaults. Example: secret "my-api-key" { description "API key for external service" } </summary>
custom operation: description (string) Calls SecretsManagerBuilder.Description
custom operation: generateSecretString (SecretStringGenerator) Calls SecretsManagerBuilder.GenerateSecretString
module SecretsManagerHelpers from FsCDK
<summary> Helper functions for creating secret string generators </summary>
val generateJsonSecret: secretStringTemplate: string -> generateStringKey: string -> SecretStringGenerator
<summary> Creates a secret string generator for JSON secrets (e.g., database credentials) </summary>
val appVpc: IVpc
val vpc: name: string -> VpcBuilder
<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>
custom operation: maxAzs (int) Calls VpcBuilder.MaxAzs
<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>
val rdsInstance: name: string -> DatabaseInstanceBuilder
<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>
custom operation: vpc (IVpc) Calls DatabaseInstanceBuilder.Vpc
<summary>Sets the VPC.</summary>
custom operation: postgresEngine (PostgresEngineVersion option) Calls DatabaseInstanceBuilder.PostgresEngine
<summary>Sets PostgreSQL as the database engine with a specific version.</summary>
type PostgresEngineVersion = inherit DeputyBase static member Of: postgresFullVersion: string * postgresMajorVersion: string * ?postgresFeatures: IPostgresEngineFeatures -> PostgresEngineVersion member PostgresFullVersion: string member PostgresMajorVersion: string static member VER_10: PostgresEngineVersion static member VER_10_1: PostgresEngineVersion static member VER_10_10: PostgresEngineVersion static member VER_10_11: PostgresEngineVersion static member VER_10_12: PostgresEngineVersion static member VER_10_13: PostgresEngineVersion ...
property PostgresEngineVersion.VER_15: PostgresEngineVersion with get
custom operation: instanceType (InstanceType) Calls DatabaseInstanceBuilder.InstanceType
<summary>Sets the instance type.</summary>
Multiple items
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
InstanceType.Of(instanceClass: InstanceClass, instanceSize: InstanceSize) : InstanceType
[<Struct>] type InstanceClass = | STANDARD3 = 0 | M3 = 1 | STANDARD4 = 2 | M4 = 3 | STANDARD5 = 4 | M5 = 5 | STANDARD5_NVME_DRIVE = 6 | M5D = 7 | STANDARD5_AMD = 8 | M5A = 9 ...
field InstanceClass.MEMORY5: InstanceClass = 22
[<Struct>] type InstanceSize = | NANO = 0 | MICRO = 1 | SMALL = 2 | MEDIUM = 3 | LARGE = 4 | XLARGE = 5 | XLARGE2 = 6 | XLARGE3 = 7 | XLARGE4 = 8 | XLARGE6 = 9 ...
field InstanceSize.LARGE: InstanceSize = 4
custom operation: databaseName (string) Calls DatabaseInstanceBuilder.DatabaseName
<summary>Sets the database name.</summary>
custom operation: allocatedStorage (int) Calls DatabaseInstanceBuilder.AllocatedStorage
<summary>Sets the allocated storage in GB.</summary>
custom operation: multiAz (bool) Calls DatabaseInstanceBuilder.MultiAz
<summary>Enables or disables Multi-AZ deployment.</summary>
custom operation: iamAuthentication (bool) Calls DatabaseInstanceBuilder.IamAuthentication
<summary>Enables IAM authentication.</summary>
custom operation: storageEncrypted (bool) Calls DatabaseInstanceBuilder.StorageEncrypted
<summary>Enables storage encryption.</summary>
custom operation: deletionProtection (bool) Calls DatabaseInstanceBuilder.DeletionProtection
<summary>Enables or disables deletion protection.</summary>
field InstanceSize.XLARGE: InstanceSize = 5
custom operation: backupRetentionDays (float) Calls DatabaseInstanceBuilder.BackupRetentionDays
<summary>Sets the backup retention period in days.</summary>
custom operation: enablePerformanceInsights (bool) Calls DatabaseInstanceBuilder.EnablePerformanceInsights
<summary>Enables performance insights.</summary>
val tenantId: obj
val tenantRole: obj
val users: obj
val user: obj

Type something to start searching.