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.
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.
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 sessionTenant 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.
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.
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'
}
]
}
}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.
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'
}
}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.
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'
}
}
}
]
}
}
}
}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.
- 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' }}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.