AzureDevOpsPostgreSQLIaC

Multi-Tenant SaaS on Azure: From VNet to Multi-Schema PostgreSQL

January 2025

Building MAiQ on Azure meant dealing with EU data residency requirements, multi-tenant isolation, and the operational complexity of a distributed AI system — all at once. This post covers the infrastructure decisions that shaped the architecture.

Multi-Tenancy Strategy: Schema-per-Tenant

We chose schema-per-tenant isolation in a single PostgreSQL instance over separate databases or row-level isolation. It gives strong logical separation without the operational overhead of managing hundreds of databases, and PostgreSQL's search_path makes routing transparent at the connection level.

python
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from contextlib import asynccontextmanager

@asynccontextmanager
async def tenant_session(tenant_schema: str):
    engine = create_async_engine(settings.DATABASE_URL)
    async_session = sessionmaker(engine, class_=AsyncSession)

    async with async_session() as session:
        # Set search_path to isolate tenant data
        await session.execute(
            text(f"SET search_path TO {tenant_schema}, public")
        )
        yield session

Tenant schema creation and migration runs on every new signup via an async background task. We use Alembic with a custom env.py that iterates over all tenant schemas and applies pending migrations.

Azure Container Apps with Private VNet

All services run as Azure Container Apps (ACA) inside a private VNet. The VNet has two subnets: one for ACA infrastructure and one for private endpoints (PostgreSQL, Redis, Blob Storage, Key Vault). Nothing is exposed to the public internet except through the ACA ingress, which terminates TLS.

bicep
resource containerAppEnv 'Microsoft.App/managedEnvironments@2023-05-01' = {
  name: 'maiq-env-${environment}'
  location: location
  properties: {
    vnetConfiguration: {
      infrastructureSubnetId: infraSubnet.id
      internal: false
    }
    workloadProfiles: [
      {
        name: 'Consumption'
        workloadProfileType: 'Consumption'
      }
    ]
  }
}

Managed Identities & Key Vault

No secrets are stored as environment variables. Every service has a system-assigned managed identity, and all secrets — database passwords, API keys, storage connection strings — live in Azure Key Vault. Container Apps pulls secrets from Key Vault at startup via Key Vault references.

bicep
resource keyVaultAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(keyVault.id, containerApp.id, 'KeyVaultSecretsUser')
  scope: keyVault
  properties: {
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      '4633458b-17de-408a-b874-0445c86b69e6' // Key Vault Secrets User
    )
    principalId: containerApp.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

Async Backend with Redis Streams & KEDA

Long-running AI tasks (document ingestion, vectorization, billing reconciliation) are queued via Redis Streams. Worker Container Apps process these streams asynchronously. KEDA (Kubernetes Event-Driven Autoscaling) scales workers up and down based on stream lag — zero workers at idle, scaling out instantly under load.

bicep
resource workerScaleRule 'Microsoft.App/containerApps@2023-05-01' = {
  properties: {
    template: {
      scale: {
        minReplicas: 0
        maxReplicas: 10
        rules: [
          {
            name: 'redis-stream-scaler'
            custom: {
              type: 'redis-streams'
              metadata: {
                address: redisHost
                stream: 'ingestion-jobs'
                pendingEntriesCount: '5'
              }
            }
          }
        ]
      }
    }
  }
}

CI/CD with GitHub Actions

Every push to main triggers a GitHub Actions pipeline that builds Docker images, pushes to Azure Container Registry (ACR), and deploys to Container Apps via az containerapp update. Staging and production are separate Container App Environments in separate resource groups. Bicep IaC handles all infrastructure — no manual portal clicks.

yaml
- name: Deploy to Azure Container Apps
  run: |
    az containerapp update \
      --name maiq-api \
      --resource-group rg-maiq-${{ env.ENVIRONMENT }} \
      --image ${{ env.ACR_LOGIN_SERVER }}/maiq-api:${{ github.sha }}
  env:
    ENVIRONMENT: ${{ github.ref == 'refs/heads/main' && 'prod' || 'staging' }}

EU Compliance Considerations

All resources are deployed to West Europe (Amsterdam). Azure OpenAI endpoints are also in West Europe to keep data processing within the EU. We use Azure Policy to deny creation of resources outside approved regions. PostgreSQL uses customer-managed encryption keys stored in Key Vault. Blob Storage has versioning and soft delete enabled for data recovery requirements under GDPR.