Skip to main content

SaaS Multi-Tenant Architecture: Complete Guide

October 8, 2025By CTO23 min read
...
architectures

A comprehensive guide to designing and implementing multi-tenant SaaS architectures with data isolation, performance, and scalability in mind.

Scale: 10K-1M users
Tech Stack: PostgreSQL, Node.js, Redis, AWS

SaaS Multi-Tenant Architecture: Complete Guide

Multi-tenancy is the foundation of SaaS applications, allowing a single application instance to serve multiple customers (tenants) efficiently. Here's everything you need to design and build a robust multi-tenant architecture.

Multi-Tenancy Models

1. Shared Database, Shared Schema

Structure:

┌─────────────────────────┐
│     Application         │
└───────────┬─────────────┘
            │
    ┌───────▼────────┐
    │   Database     │
    │  ┌──────────┐  │
    │  │   users  │  │ ← tenant_id column
    │  ├──────────┤  │
    │  │   posts  │  │ ← tenant_id column
    │  └──────────┘  │
    └────────────────┘

Implementation:

sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255),
    UNIQUE(tenant_id, email)
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    user_id INTEGER NOT NULL REFERENCES users(id),
    title VARCHAR(255),
    content TEXT,
    CHECK (tenant_id = (SELECT tenant_id FROM users WHERE id = user_id))
);

Pros:

  • Lowest infrastructure cost
  • Simplest to operate
  • Easy resource sharing
  • Simple backup/restore

Cons:

  • Highest data leak risk
  • Noisy neighbor problems
  • Harder to customize per tenant
  • Complex queries (always filter by tenant)

Best for:

  • Early-stage SaaS
  • Homogeneous tenants
  • Cost-sensitive products
  • Small to medium data per tenant

2. Shared Database, Separate Schema

Structure:

┌─────────────────────────┐
│     Application         │
└───────────┬─────────────┘
            │
    ┌───────▼────────┐
    │   Database     │
    │  ┌──────────┐  │
    │  │ tenant_a │  │ ← separate schema
    │  ├──────────┤  │
    │  │ tenant_b │  │ ← separate schema
    │  └──────────┘  │
    └────────────────┘

Implementation:

sql
-- For each tenant
CREATE SCHEMA tenant_abc;

CREATE TABLE tenant_abc.users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(255)
);

CREATE TABLE tenant_abc.posts (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES tenant_abc.users(id),
    title VARCHAR(255),
    content TEXT
);

Pros:

  • Better data isolation
  • Easier per-tenant customization
  • Cleaner queries (no tenant_id filtering)
  • Can set different retention policies

Cons:

  • More complex operations
  • Schema migration complexity
  • Connection pooling challenges
  • Limits on number of schemas

Best for:

  • Medium-scale SaaS
  • Tenants with custom fields
  • Compliance requirements
  • 100-1000 tenants

3. Separate Database

Structure:

┌─────────────────────────┐
│     Application         │
└───────────┬─────────────┘
            │
    ┌───────┴────────┐
    │                │
┌───▼────┐      ┌───▼────┐
│ DB_A   │      │ DB_B   │
│ Tenant │      │ Tenant │
│   A    │      │   B    │
└────────┘      └────────┘

Pros:

  • Maximum data isolation
  • Independent scaling
  • Easy to move tenants
  • Best security posture

Cons:

  • Highest operational cost
  • Complex management at scale
  • Expensive for small tenants
  • Hard to share resources

Best for:

  • Enterprise SaaS
  • Regulated industries
  • Large tenants
  • High-value customers

Implementation Details

Tenant Identification

Subdomain-based:

typescript
// acme.myapp.com → tenant: acme
function getTenantFromSubdomain(hostname: string): string {
  const parts = hostname.split('.');
  if (parts.length < 3) {
    throw new Error('Invalid hostname');
  }
  return parts[0];
}

app.use((req, res, next) => {
  const tenant = getTenantFromSubdomain(req.hostname);
  req.tenant = tenant;
  next();
});

Header-based:

typescript
// X-Tenant-ID: acme
app.use((req, res, next) => {
  const tenant = req.headers['x-tenant-id'];
  if (!tenant) {
    return res.status(400).json({ error: 'Missing tenant' });
  }
  req.tenant = tenant;
  next();
});

Path-based:

typescript
// /api/tenants/acme/users
app.use('/api/tenants/:tenantId/*', (req, res, next) => {
  req.tenant = req.params.tenantId;
  next();
});

Database Access Layer

Shared Schema Implementation:

typescript
import { Pool } from 'pg';

export class TenantDatabase {
  constructor(private pool: Pool) {}

  async query<T>(
    tenantId: string,
    sql: string,
    params: any[] = []
  ): Promise<T[]> {
    // Automatically inject tenant_id into WHERE clause
    const client = await this.pool.connect();
    try {
      // Set tenant context for Row Level Security
      await client.query('SET app.current_tenant = $1', [tenantId]);

      const result = await client.query(sql, params);
      return result.rows;
    } finally {
      client.release();
    }
  }
}

// Usage
const db = new TenantDatabase(pool);
const users = await db.query(
  tenantId,
  'SELECT * FROM users WHERE email = $1',
  ['user@example.com']
);

Row Level Security (PostgreSQL):

sql
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

-- Create policy
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- Now queries automatically filter by tenant
SET app.current_tenant = 'tenant-uuid';
SELECT * FROM users;  -- Only returns rows for this tenant

Separate Schema Implementation:

typescript
export class MultiSchemaDatabase {
  constructor(private pool: Pool) {}

  async query<T>(
    tenantId: string,
    sql: string,
    params: any[] = []
  ): Promise<T[]> {
    const schema = this.getSchemaName(tenantId);
    const client = await this.pool.connect();

    try {
      // Set search path to tenant schema
      await client.query(`SET search_path TO ${schema}`);

      const result = await client.query(sql, params);
      return result.rows;
    } finally {
      client.release();
    }
  }

  private getSchemaName(tenantId: string): string {
    // Sanitize tenant ID for use as schema name
    return `tenant_${tenantId.replace(/[^a-z0-9_]/gi, '')}`;
  }
}

Connection Pooling

typescript
import { Pool } from 'pg';

// Single pool for all tenants (shared database)
const sharedPool = new Pool({
  max: 20,
  connectionTimeoutMillis: 2000,
});

// Pool per tenant (separate databases)
class TenantPoolManager {
  private pools = new Map<string, Pool>();

  getPool(tenantId: string): Pool {
    if (!this.pools.has(tenantId)) {
      const config = this.getTenantConfig(tenantId);
      this.pools.set(tenantId, new Pool(config));
    }
    return this.pools.get(tenantId)!;
  }

  private getTenantConfig(tenantId: string) {
    // Fetch from tenant registry
    return {
      host: `${tenantId}.db.myapp.com`,
      database: `tenant_${tenantId}`,
      max: 10,
    };
  }
}

Tenant Management

Tenant Registry

typescript
interface Tenant {
  id: string;
  name: string;
  subdomain: string;
  plan: 'free' | 'basic' | 'premium' | 'enterprise';
  status: 'active' | 'suspended' | 'deleted';
  databaseConfig: {
    type: 'shared' | 'dedicated';
    host?: string;
    database?: string;
    schema?: string;
  };
  features: string[];
  limits: {
    maxUsers: number;
    maxStorage: number;
    maxApiCalls: number;
  };
  createdAt: Date;
  updatedAt: Date;
}

export class TenantRegistry {
  constructor(private redis: Redis, private db: Pool) {}

  async getTenant(identifier: string): Promise<Tenant | null> {
    // Try cache first
    const cached = await this.redis.get(`tenant:${identifier}`);
    if (cached) {
      return JSON.parse(cached);
    }

    // Fetch from database
    const result = await this.db.query(
      'SELECT * FROM tenants WHERE id = $1 OR subdomain = $1',
      [identifier]
    );

    if (result.rows.length === 0) {
      return null;
    }

    const tenant = result.rows[0];

    // Cache for 5 minutes
    await this.redis.setex(
      `tenant:${identifier}`,
      300,
      JSON.stringify(tenant)
    );

    return tenant;
  }

  async createTenant(data: Partial<Tenant>): Promise<Tenant> {
    // Generate unique ID
    const id = uuidv4();

    // Create tenant record
    const result = await this.db.query(`
      INSERT INTO tenants (id, name, subdomain, plan, status, database_config, features, limits)
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
      RETURNING *
    `, [
      id,
      data.name,
      data.subdomain,
      data.plan || 'free',
      'active',
      data.databaseConfig,
      data.features || [],
      data.limits
    ]);

    const tenant = result.rows[0];

    // Provision tenant resources
    await this.provisionTenant(tenant);

    return tenant;
  }

  private async provisionTenant(tenant: Tenant): Promise<void> {
    if (tenant.databaseConfig.type === 'shared') {
      // Create schema for tenant
      await this.db.query(`CREATE SCHEMA IF NOT EXISTS tenant_${tenant.id}`);

      // Run migrations
      await this.runMigrations(tenant.id);
    } else {
      // Create dedicated database
      await this.createDedicatedDatabase(tenant);
    }
  }
}

Tenant Middleware

typescript
import { Request, Response, NextFunction } from 'express';

export function tenantMiddleware(registry: TenantRegistry) {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      // Identify tenant
      const tenantId = getTenantIdentifier(req);
      if (!tenantId) {
        return res.status(400).json({ error: 'Tenant not specified' });
      }

      // Load tenant
      const tenant = await registry.getTenant(tenantId);
      if (!tenant) {
        return res.status(404).json({ error: 'Tenant not found' });
      }

      // Check tenant status
      if (tenant.status !== 'active') {
        return res.status(403).json({
          error: 'Tenant suspended',
          status: tenant.status
        });
      }

      // Attach to request
      req.tenant = tenant;
      next();
    } catch (error) {
      next(error);
    }
  };
}

function getTenantIdentifier(req: Request): string | null {
  // Try subdomain
  const subdomain = req.hostname.split('.')[0];
  if (subdomain && subdomain !== 'www' && subdomain !== 'api') {
    return subdomain;
  }

  // Try header
  const header = req.headers['x-tenant-id'];
  if (header) {
    return header as string;
  }

  // Try path
  const match = req.path.match(/^\/tenants\/([^/]+)/);
  if (match) {
    return match[1];
  }

  return null;
}

Security Considerations

1. Data Isolation Testing

typescript
describe('Tenant Data Isolation', () => {
  it('should not allow cross-tenant data access', async () => {
    // Create data for tenant A
    await db.query('tenant-a', 'INSERT INTO users (email) VALUES ($1)', ['a@example.com']);

    // Try to access from tenant B
    const users = await db.query('tenant-b', 'SELECT * FROM users WHERE email = $1', ['a@example.com']);

    expect(users).toHaveLength(0);
  });

  it('should enforce RLS policies', async () => {
    const directQuery = await pool.query('SELECT * FROM users');
    const tenantQuery = await db.query('tenant-a', 'SELECT * FROM users');

    // Direct query should be blocked by RLS
    expect(directQuery.rows).toHaveLength(0);
  });
});

2. Input Validation

typescript
function validateTenantIdentifier(identifier: string): boolean {
  // Only allow alphanumeric and hyphens
  return /^[a-z0-9-]{3,63}$/.test(identifier);
}

function sanitizeForSql(value: string): string {
  // Remove any SQL injection attempts
  return value.replace(/[';\"\\]/g, '');
}

3. Rate Limiting Per Tenant

typescript
export class TenantRateLimiter {
  constructor(private redis: Redis) {}

  async checkLimit(tenantId: string, limit: number): Promise<boolean> {
    const key = `ratelimit:tenant:${tenantId}:${getCurrentMinute()}`;
    const count = await this.redis.incr(key);

    if (count === 1) {
      await this.redis.expire(key, 60);
    }

    return count <= limit;
  }
}

app.use(async (req, res, next) => {
  const limit = req.tenant.limits.maxApiCalls;
  const allowed = await rateLimiter.checkLimit(req.tenant.id, limit);

  if (!allowed) {
    return res.status(429).json({
      error: 'Rate limit exceeded',
      limit: limit
    });
  }

  next();
});

Performance Optimization

1. Tenant Caching

typescript
export class TenantCache {
  constructor(private redis: Redis) {}

  async get<T>(tenantId: string, key: string): Promise<T | null> {
    const fullKey = `tenant:${tenantId}:${key}`;
    const cached = await this.redis.get(fullKey);
    return cached ? JSON.parse(cached) : null;
  }

  async set(tenantId: string, key: string, value: any, ttl: number = 300): Promise<void> {
    const fullKey = `tenant:${tenantId}:${key}`;
    await this.redis.setex(fullKey, ttl, JSON.stringify(value));
  }

  async invalidate(tenantId: string, pattern: string = '*'): Promise<void> {
    const keys = await this.redis.keys(`tenant:${tenantId}:${pattern}`);
    if (keys.length > 0) {
      await this.redis.del(...keys);
    }
  }
}

2. Query Optimization

sql
-- Always index tenant_id
CREATE INDEX CONCURRENTLY idx_users_tenant_email
ON users(tenant_id, email);

-- Partial indexes for common queries
CREATE INDEX CONCURRENTLY idx_active_users
ON users(tenant_id, email)
WHERE status = 'active';

-- Partition large tables by tenant
CREATE TABLE users (
    id BIGSERIAL,
    tenant_id UUID NOT NULL,
    email VARCHAR(255),
    ...
) PARTITION BY HASH (tenant_id);

3. Connection Pool Sizing

typescript
// Formula: pool size = (core_count * 2) + effective_spindle_count
const poolConfig = {
  // For shared database
  shared: {
    min: 5,
    max: 20,
    idleTimeoutMillis: 30000,
  },

  // For dedicated databases (per tenant)
  dedicated: {
    min: 2,
    max: 10,
    idleTimeoutMillis: 60000,
  }
};

Monitoring and Observability

typescript
export class TenantMetrics {
  constructor(private metrics: MetricsClient) {}

  recordRequest(tenantId: string, endpoint: string, duration: number): void {
    this.metrics.histogram('tenant.request.duration', duration, {
      tenant: tenantId,
      endpoint: endpoint
    });
  }

  recordDatabaseQuery(tenantId: string, duration: number): void {
    this.metrics.histogram('tenant.db.query.duration', duration, {
      tenant: tenantId
    });
  }

  recordError(tenantId: string, error: Error): void {
    this.metrics.increment('tenant.errors', {
      tenant: tenantId,
      type: error.constructor.name
    });
  }
}

// Usage
app.use((req, res, next) => {
  const start = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - start;
    metrics.recordRequest(req.tenant.id, req.path, duration);
  });

  next();
});

Deployment Architecture

┌──────────────────────────────────────────────────┐
│                  Load Balancer                    │
│              (Route by Subdomain)                 │
└──────────┬───────────────────────────────────────┘
           │
    ┌──────┴──────┐
    │             │
┌───▼────┐    ┌──▼─────┐
│  App   │    │  App   │
│Server 1│    │Server 2│
└───┬────┘    └───┬────┘
    │             │
    └──────┬──────┘
           │
    ┌──────▼──────┐
    │   Redis     │
    │   Cache     │
    └──────┬──────┘
           │
    ┌──────▼───────────────┐
    │   PostgreSQL         │
    │  (Shared/Dedicated)  │
    └──────────────────────┘

Migration Strategies

Shared → Separate Schema

typescript
async function migrateToSeparateSchema(tenantId: string): Promise<void> {
  const schema = `tenant_${tenantId}`;

  // 1. Create new schema
  await db.query(`CREATE SCHEMA ${schema}`);

  // 2. Run schema migrations
  await runMigrations(schema);

  // 3. Copy data
  await db.query(`
    INSERT INTO ${schema}.users
    SELECT id, email, name FROM public.users
    WHERE tenant_id = $1
  `, [tenantId]);

  // 4. Verify data
  const oldCount = await db.query('SELECT COUNT(*) FROM public.users WHERE tenant_id = $1', [tenantId]);
  const newCount = await db.query(`SELECT COUNT(*) FROM ${schema}.users`);

  if (oldCount.rows[0].count !== newCount.rows[0].count) {
    throw new Error('Migration verification failed');
  }

  // 5. Update tenant config
  await updateTenantConfig(tenantId, { schema });

  // 6. Delete old data
  await db.query('DELETE FROM public.users WHERE tenant_id = $1', [tenantId]);
}

Best Practices

1. Always Test Data Isolation

  • Write automated tests
  • Simulate cross-tenant access attempts
  • Regular security audits

2. Plan for Tenant Migration

  • Build tooling early
  • Support multiple models simultaneously
  • Make migrations zero-downtime

3. Monitor Per-Tenant Metrics

  • Resource usage
  • Error rates
  • Performance
  • Costs

4. Implement Soft Deletes

sql
ALTER TABLE tenants ADD COLUMN deleted_at TIMESTAMP;
CREATE INDEX idx_tenants_active ON tenants(id) WHERE deleted_at IS NULL;

5. Design for Tenant Portability

  • Standard export format
  • Easy backup/restore
  • Clear data boundaries

Conclusion

Multi-tenancy is a spectrum, not a binary choice. Start simple (shared database, shared schema), and evolve based on your specific needs around security, compliance, performance, and scale.

Remember: The best architecture is the one you can operate reliably.


What multi-tenancy model are you using? What challenges have you faced?

Want more insights like this?

Join thousands of CTOs and technical leaders getting weekly insights on leadership and system design.

No spam. Unsubscribe anytime.