Skip to content

Multi-Site & Multi-Tenant Architecture

Single-tenant architectures assume one enterprise operating one platform. Multi-tenant is required when: (a) an ISV sells IoT-as-a-service to multiple customers, or (b) a large enterprise needs to isolate divisions or regions with separate data ownership. The isolation level required (logical vs. physical) drives the cost significantly — a factor of 3–10× between shared-everything and dedicated-per-tenant. Getting this decision wrong in either direction is expensive: under-isolating causes a data breach incident; over-isolating makes the unit economics unviable.

20.1 Isolation Levels

Isolation Model Cost Isolation Strength Notes
Shared everything (topic prefix only) Lowest Weakest — single broker failure affects all tenants Acceptable for internal enterprise divisions with shared IT ownership
Shared platform, dedicated broker per tenant Moderate Strong — broker failure affects one tenant only Good for regulated industries (one tenant cannot see another's traffic)
Dedicated platform per tenant Highest (3–5× shared) Full — complete infrastructure separation Required for government, defense, and some financial services contracts
Hybrid: shared ingestion, dedicated storage Moderate Strong data isolation; weaker transport isolation Common balance — most tenants care more about data than transport isolation

20.2 Multi-Tenant MQTT Topic Design

Topic structure encodes the tenant ID as the first path element, making ACL enforcement at the broker straightforward:

{tenant_id}/{site}/{area}/{device_type}/{device_id}/telemetry
{tenant_id}/{site}/{area}/{device_type}/{device_id}/status
{tenant_id}/{site}/{area}/{device_type}/{device_id}/commands

Broker ACL rule (EMQX format): each device's TLS certificate Common Name (CN) encodes the tenant ID. The auth plugin maps CN → tenant_id and enforces that the device can only publish or subscribe to topics beginning with their own tenant_id. A device with CN tenant-a::GW-007 cannot publish to tenant-b/... regardless of what topic it attempts to use.

This design means a compromised device can only affect its own tenant's topics — it cannot inject data into another tenant's namespace.

20.3 Data Isolation in TimescaleDB

Pattern 1: Row-Level Security (shared tables)

All tenants share the same hypertables with a tenant_id column. Postgres RLS policies enforce that each application user (one per tenant) can only see rows with their own tenant_id.

-- Create the policy
CREATE POLICY tenant_isolation ON telemetry
    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- Enable RLS on the table
ALTER TABLE telemetry ENABLE ROW LEVEL SECURITY;

-- Set tenant context at connection time
SET app.current_tenant_id = 'tenant-a-uuid';
-- All subsequent queries on this connection only see tenant-a rows

Advantage: single schema migration affects all tenants simultaneously. Disadvantage: a schema bug or RLS misconfiguration could expose one tenant's data to another — test rigorously and audit quarterly.

Pattern 2: Schema-per-tenant

Each tenant gets their own Postgres schema (tenant_a.telemetry, tenant_b.telemetry). The application user for each tenant has USAGE rights only on their own schema.

-- Create tenant schema
CREATE SCHEMA tenant_a;
CREATE TABLE tenant_a.telemetry (LIKE public.telemetry INCLUDING ALL);

-- Grant access only to tenant-a application user
GRANT USAGE ON SCHEMA tenant_a TO app_user_tenant_a;
GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA tenant_a TO app_user_tenant_a;

-- Cross-tenant analytics (admin only, not exposed to tenants)
SELECT 'tenant_a' AS tenant, COUNT(*) FROM tenant_a.telemetry WHERE ts > NOW() - INTERVAL '1 day'
UNION ALL
SELECT 'tenant_b' AS tenant, COUNT(*) FROM tenant_b.telemetry WHERE ts > NOW() - INTERVAL '1 day';

20.4 Multi-Tenant Architecture Diagram

graph TB
    DEVS_A["Tenant A Devices<br/>tenant-a/... topics"]
    DEVS_B["Tenant B Devices<br/>tenant-b/... topics"]

    BROKER["Shared MQTT Broker Cluster<br/>Per-tenant ACL via TLS CN<br/>Topic prefix enforcement"]

    INGEST["Shared Ingestion Workers<br/>Extract tenant_id from topic<br/>Route to correct schema"]

    DB_A["Tenant A Schema<br/>TimescaleDB<br/>tenant_a.*"]
    DB_B["Tenant B Schema<br/>TimescaleDB<br/>tenant_b.*"]

    API_A["Tenant A API<br/>JWT: tenant_id=A"]
    API_B["Tenant B API<br/>JWT: tenant_id=B"]

    DASH_A["Tenant A Dashboard"]
    DASH_B["Tenant B Dashboard"]

    ADMIN["Admin Plane<br/>Tenant provisioning<br/>Billing metering<br/>Cross-tenant analytics<br/>(aggregated, no PII)"]

    DEVS_A --> BROKER
    DEVS_B --> BROKER
    BROKER --> INGEST
    INGEST --> DB_A
    INGEST --> DB_B
    DB_A --> API_A --> DASH_A
    DB_B --> API_B --> DASH_B
    ADMIN --> BROKER
    ADMIN --> DB_A
    ADMIN --> DB_B