Skip to main content
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.
This page documents 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 / recent az CLI), authenticated with az login against the target subscription.
  • An existing Azure Container Registry (ACR)deploy/azure does not create one. Pass its name as containerRegistryName and, 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/frontendImage are 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/Dockerfile and this repo’s root Dockerfile.
  • Three password values supplied as environment variables, never committed to main.bicepparam: AZURE_DATABASE_OWNER_PASSWORD, AZURE_APP_DATABASE_PASSWORD, and AZURE_INITIAL_ADMIN_PASSWORD (at least 8 characters — enforced by a @minLength(8) decorator on initialAdminPassword). main.bicepparam reads each with readEnvironmentVariable(...).
  • A resource group to deploy into (the template’s targetScope is resourceGroup).

Topology

Unlike AWS’s ALB + private service-discovery namespace, Azure gives every Container App its own ingress: the frontend’s is external: 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

A single 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.
  • Microsoft.DBforPostgreSQL/flexibleServers — PostgreSQL 16, postgresSku, VNet-injected via the postgres subnet with publicNetworkAccess: '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_ZRS in production / Standard_LRS otherwise. A blobServices child enables container/blob soft-delete (30 days) and blob versioning; a causeloop-uploads container holds tenant objects; a lifecycle management policy deletes blobs under the tenants/ prefix after objectRetentionDays and old versions after 30 days. A private endpoint plus DNS zone group complete the private-access path.
  • Microsoft.Cache/redisEnterprise — Azure Managed Redis, Balanced_B0 SKU, TLS 1.2 minimum, publicNetworkAccess: 'Disabled', high availability enabled in production. The child databases resource uses clientProtocol: 'Encrypted' and clusteringPolicy: 'EnterpriseCluster' specifically so the application’s standard redis-py client 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.
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.
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.
Five distinct user-assigned managed identities, one per privilege boundary: 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 both app-database-url and redis-url secrets individually (not the whole vault).
  • provisionerIdentity → Key Vault Secrets User on app-database-url and owner-database-url individually, 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-in AcrPull role to all five identities’ principal IDs against the registry — this is the only permission any identity needs to retrieve images.
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):
  • migration job — identity migrationIdentity, command = ["python", "scripts/migrate_product.py"], secrets owner-database-url and app-database-password passed as plain Container Apps job secrets (not Key Vault references), replicaRetryLimit: 1, replicaTimeout: 1800 seconds.
  • admin-bootstrap job — identity adminBootstrapIdentity, command = ["python", "scripts/create_employee.py", "--email", initialAdminEmail, "--name", initialAdminName, "--role", "onboarding_admin", "--password-env", "CAUSELOOP_BOOTSTRAP_ADMIN_PASSWORD"], secrets app-database-url and bootstrap-admin-password, replicaTimeout: 600 seconds. 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.
Both jobs dependsOn: [acrPullAssignments, applicationDatabase] so they never race image-pull RBAC or database creation.
Four Microsoft.App/containerApps resources, each guarded by if (activateServices) so they don’t exist at all in the initial deployment:
  • api — identity runtimeIdentity, internal ingress on port 8000, liveness probe /health/live and readiness probe /health/ready (20s initial delay, 30s/10s periods respectively), apiEnvironment (runtime + email env) plus APP_DATABASE_URL/REDIS_URL from Key-Vault-backed Container Apps secrets, 1 vCPU / 2 GiB, scaled 2–4 replicas in production (1–1 otherwise).
  • product-worker — identity runtimeIdentity, 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 — identity provisionerIdentity, its own Key-Vault-referenced secrets (owner-database-url, app-database-url) rather than the shared appSecrets, CAUSELOOP_WORKER_JOB_TYPES=provision_tenant, fixed at 1 replica.
  • frontend — identity frontendIdentity, external ingress on port 3000, CAUSELOOP_API_URL built from the (deployed) api app’s FQDN, liveness probe /login and readiness probe /api/backend/health/ready, scaled 2–4 replicas in production (1–1 otherwise).
See Workers and jobs for what materialize_insights, send_email, train_tenant_model, and provision_tenant actually do.
Microsoft.Insights/diagnosticSettings on the Postgres server streams all log categories and all metrics to the Log Analytics workspace.

Deploy procedure

1

Push immutable images

Push tagged frontend and backend images into the existing ACR referenced by containerRegistryName before the first deployment.
2

Set the three password environment variables

3

Deploy with activateServices=false

This creates the VNet, Postgres, Redis, Blob, Key Vault, identities/RBAC, the Container Apps environment, and both jobs — but none of the four long-running Container Apps, since they’re guarded by if (activateServices).
4

Start and verify the migration job

Require a successful execution before proceeding — check the job’s execution history/logs in the portal or with az containerapp job execution list.
5

Start and verify the administrator-bootstrap job

Again, require a successful execution before proceeding.
6

Confirm email sender and role assignment

Confirm the emailSenderAddress deployment output and that the runtime identity’s custom email-sender role assignment against the Communication Service exists.
7

Redeploy with activateServices=true

Only after both jobs have succeeded — this creates the api, product-worker, provisioner-worker, and frontend Container Apps.
8

Rotate the initial administrator credential

Sign in as the initial administrator, redeploy with a newly generated 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.
9

Run the public-path smoke check

Then deliver and accept one real invitation, and verify a real upload, job retry, tenant isolation, and backup status — the same acceptance checklist as AWS deployment.

How this differs from AWS

Both stacks share the same non-negotiables: the API never receives migration credentials, the migration task/job and provisioner are the only identities that can perform schema DDL, the administrator-bootstrap identity can only reach the app DSN and its own password secret, and neither cloud sends a real email as a health-check side effect.