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:

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:

-- 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:

// 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:

// 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:

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

Database Access Layer

Shared Schema Implementation:

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):

-- 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:

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

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

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

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

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

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

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

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

-- 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

// 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

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

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

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?