deploy/azure is a compiled Bicep deployment (main.bicep + main.bicepparam) that mirrors the local product topology (infra/docker-compose.product.yml) with Azure-managed services: external/internal Container Apps, private PostgreSQL Flexible Server, private Azure Managed Redis, private versioned Blob Storage, Key Vault credential encryption, and Azure Communication Services Email. See Cloud environments for how Azure fits alongside AWS and the local stack, and AWS deployment for the equivalent Terraform stack this page will keep comparing against.
deploy/azure/main.bicep, main.bicepparam, and acr-pull.bicep as committed. Where docs/CLOUD_DEPLOYMENT.md and the Bicep disagree, the Bicep wins.Prerequisites
- An Azure subscription with permission to create a VNet, Container Apps environment, PostgreSQL Flexible Server, Storage account, Key Vault, Azure Managed Redis, Communication Services resources, managed identities, and RBAC role assignments/definitions in the target resource group.
- The Bicep CLI (
az bicep/ recentazCLI), authenticated withaz loginagainst the target subscription. - An existing Azure Container Registry (ACR) —
deploy/azuredoes not create one. Pass its name ascontainerRegistryNameand, if it lives in a different resource group than the deployment,containerRegistryResourceGroup. - Immutable frontend and backend images already pushed into that ACR before the first deployment —
backendImage/frontendImageare repository:tag strings (without the registry hostname), referenced as${registry.properties.loginServer}/${backendImage}inside the template. These are the same two images documented in Cloud environments:../frontend/Dockerfileand this repo’s rootDockerfile. - Three password values supplied as environment variables, never committed to
main.bicepparam:AZURE_DATABASE_OWNER_PASSWORD,AZURE_APP_DATABASE_PASSWORD, andAZURE_INITIAL_ADMIN_PASSWORD(at least 8 characters — enforced by a@minLength(8)decorator oninitialAdminPassword).main.bicepparamreads each withreadEnvironmentVariable(...). - A resource group to deploy into (the template’s
targetScopeisresourceGroup).
Topology
Unlike AWS’s ALB + private service-discovery namespace, Azure gives every Container App its own ingress: the frontend’s isexternal: true, and the API’s is external: false (internal-only within the Container Apps environment) — the frontend still reaches the API over https://<api-app-fqdn>, injected as CAUSELOOP_API_URL at deploy time via api!.properties.configuration.ingress.fqdn.
Parameters (main.bicepparam / main.bicep)
main.bicepparam pins location = 'uksouth', environment = 'production', namePrefix = 'causeloop', and reads the three passwords from environment variables — fill in containerRegistryName, containerRegistryResourceGroup, backendImage, frontendImage, initialAdminEmail, and initialAdminName for your account before deploying.
What main.bicep provisions
Networking
Networking
Microsoft.Network/virtualNetworks (10.42.0.0/16) with three delegated/dedicated subnets: container-apps (10.42.0.0/23, delegated to Microsoft.App/environments), postgres (10.42.2.0/24, delegated to Microsoft.DBforPostgreSQL/flexibleServers), and private-endpoints (10.42.3.0/24, with private endpoint network policies disabled). Four private DNS zones (Postgres, Blob, Key Vault, Redis) are each linked to this VNet.Data services
Data services
Microsoft.DBforPostgreSQL/flexibleServers— PostgreSQL 16,postgresSku, VNet-injected via thepostgressubnet withpublicNetworkAccess: 'Disabled'and a private DNS zone, geo-redundant backup enabled in production, and zone-redundant high availability in production (disabled otherwise).Microsoft.Storage/storageAccounts(Blob) —StorageV2,allowBlobPublicAccess: false,allowSharedKeyAccess: false(identity-only access — no storage account keys),defaultToOAuthAuthentication: true, TLS 1.2 minimum,publicNetworkAccess: 'Disabled',Standard_ZRSin production /Standard_LRSotherwise. AblobServiceschild enables container/blob soft-delete (30 days) and blob versioning; acauseloop-uploadscontainer holds tenant objects; a lifecycle management policy deletes blobs under thetenants/prefix afterobjectRetentionDaysand old versions after 30 days. A private endpoint plus DNS zone group complete the private-access path.Microsoft.Cache/redisEnterprise— Azure Managed Redis,Balanced_B0SKU, TLS 1.2 minimum,publicNetworkAccess: 'Disabled', high availability enabled in production. The childdatabasesresource usesclientProtocol: 'Encrypted'andclusteringPolicy: 'EnterpriseCluster'specifically so the application’s standardredis-pyclient can speak the classic Redis protocol against it — Azure Cache for Redis (the older, non-Enterprise offering) is deliberately not used because it is on a retirement path. A private endpoint plus DNS zone group complete the private-access path.
Key Vault and credential encryption
Key Vault and credential encryption
Microsoft.KeyVault/vaults with enableRbacAuthorization: true, purge protection and soft-delete both enabled, publicNetworkAccess: 'Disabled', and a default-deny network ACL (with AzureServices bypass). A 3072-bit RSA key (credential-encryption) backs application-level envelope encryption (CAUSELOOP_SECRET_PROVIDER=azure-key-vault, AZURE_KEY_VAULT_KEY_ID). Three secrets live in the vault: app-database-url, owner-database-url, and app-database-password — plus a fourth, redis-url, added once the Redis database’s primary key is available. A private endpoint plus DNS zone group complete the private-access path.Azure Communication Services Email
Azure Communication Services Email
Microsoft.Communication/emailServices plus a child domains resource with domainManagement: 'AzureManaged' (an Azure-managed sender domain — no custom domain verification needed) and a Microsoft.Communication/communicationServices resource linked to that domain. CAUSELOOP_EMAIL_FROM is derived at deploy time as DoNotReply@<emailDomain.properties.mailFromSenderDomain>, and it’s also exposed as the emailSenderAddress output.Managed identities, custom role, and RBAC
Managed identities, custom role, and RBAC
runtime (API + product-worker), frontend, migration, admin-bootstrap, and provisioner. A custom role definition (<baseName>-email-sender) grants only Microsoft.Communication/CommunicationServices/read and /write — no data-plane key — and is assigned to the runtime identity scoped to the one Communication Service, so the application never receives a Communication Services access key; it authenticates with its own token-based identity instead.Role assignments, all scoped as narrowly as the resource they touch:runtimeIdentity→ Storage Blob Data Contributor on the storage account, the custom email-sender role on the Communication Service, Key Vault Crypto User on the vault, and Key Vault Secrets User on bothapp-database-urlandredis-urlsecrets individually (not the whole vault).provisionerIdentity→ Key Vault Secrets User onapp-database-urlandowner-database-urlindividually, and Storage Blob Data Reader on the uploads container (readiness checks only — the provisioner never writes objects).frontendIdentity→ no storage, database-secret, or Key Vault role at all.acr-pull.bicep(a separate module deployed into the ACR’s own resource group) assigns the built-inAcrPullrole to all five identities’ principal IDs against the registry — this is the only permission any identity needs to retrieve images.
Container Apps environment and jobs
Container Apps environment and jobs
Microsoft.App/managedEnvironments — VNet-integrated via the container-apps subnet, internal: false (the environment itself can expose external ingress; individual apps still choose external vs. internal per-app), zone-redundant in production, logging to the Microsoft.OperationalInsights/workspaces Log Analytics workspace (30-day retention in production / 7 days otherwise).Two Microsoft.App/jobs with triggerType: 'Manual' (started explicitly with az containerapp job start, never on a schedule):migrationjob — identitymigrationIdentity,command = ["python", "scripts/migrate_product.py"], secretsowner-database-urlandapp-database-passwordpassed as plain Container Apps job secrets (not Key Vault references),replicaRetryLimit: 1,replicaTimeout: 1800seconds.admin-bootstrapjob — identityadminBootstrapIdentity,command = ["python", "scripts/create_employee.py", "--email", initialAdminEmail, "--name", initialAdminName, "--role", "onboarding_admin", "--password-env", "CAUSELOOP_BOOTSTRAP_ADMIN_PASSWORD"], secretsapp-database-urlandbootstrap-admin-password,replicaTimeout: 600seconds. Its identity can only pull the image (AcrPull) — it has no Key Vault, Blob, database-secret, or email role; the job’s own Container Apps secrets (not Key Vault) are its only source of credentials.
dependsOn: [acrPullAssignments, applicationDatabase] so they never race image-pull RBAC or database creation.Container Apps (conditional on activateServices)
Container Apps (conditional on activateServices)
Microsoft.App/containerApps resources, each guarded by if (activateServices) so they don’t exist at all in the initial deployment:api— identityruntimeIdentity, internal ingress on port 8000, liveness probe/health/liveand readiness probe/health/ready(20s initial delay, 30s/10s periods respectively),apiEnvironment(runtime + email env) plusAPP_DATABASE_URL/REDIS_URLfrom Key-Vault-backed Container Apps secrets, 1 vCPU / 2 GiB, scaled 2–4 replicas in production (1–1 otherwise).product-worker— identityruntimeIdentity, no ingress, same command/env pattern as AWS’s product-worker (CAUSELOOP_WORKER_JOB_TYPES=materialize_insights,send_email,train_tenant_model), fixed at 1 replica.provisioner-worker— identityprovisionerIdentity, its own Key-Vault-referenced secrets (owner-database-url,app-database-url) rather than the sharedappSecrets,CAUSELOOP_WORKER_JOB_TYPES=provision_tenant, fixed at 1 replica.frontend— identityfrontendIdentity, external ingress on port 3000,CAUSELOOP_API_URLbuilt from the (deployed)apiapp’s FQDN, liveness probe/loginand readiness probe/api/backend/health/ready, scaled 2–4 replicas in production (1–1 otherwise).
materialize_insights, send_email, train_tenant_model, and provision_tenant actually do.Diagnostics
Diagnostics
Microsoft.Insights/diagnosticSettings on the Postgres server streams all log categories and all metrics to the Log Analytics workspace.Deploy procedure
Push immutable images
containerRegistryName before the first deployment.Set the three password environment variables
Deploy with activateServices=false
if (activateServices).Start and verify the migration job
az containerapp job execution list.Start and verify the administrator-bootstrap job
Confirm email sender and role assignment
emailSenderAddress deployment output and that the runtime identity’s custom email-sender role assignment against the Communication Service exists.Redeploy with activateServices=true
Rotate the initial administrator credential
AZURE_INITIAL_ADMIN_PASSWORD, and deliberately rerun the admin-bootstrap job once to invalidate the initial credential — the same infrastructure-managed break-glass reset pattern as AWS; the product publishes no unused staff self-reset endpoint. Verify the new credential works.Run the public-path smoke check