> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Azure deployment

> Provision the Causeloop product stack on Azure with the deploy/azure Bicep template: every resource, every parameter, the deploy/migrate/bootstrap sequence, and how it differs from AWS.

`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](/deployment/environments) for how Azure fits alongside AWS and the local stack, and [AWS deployment](/deployment/aws) for the equivalent Terraform stack this page will keep comparing against.

<Note>
  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.
</Note>

## 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](/deployment/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

```mermaid theme={null}
flowchart TB
    subgraph "Container Apps environment (VNet-integrated)"
        FE["frontend app<br/>external ingress :3000"]
        API["api app<br/>internal ingress :8000"]
        PW[product-worker app]
        PRW[provisioner-worker app]
    end
    subgraph "Private endpoints (private-endpoints subnet)"
        PG[(PostgreSQL Flexible Server)]
        KV[(Key Vault)]
        REDIS[(Azure Managed Redis, Balanced_B0)]
        BLOB[(Blob Storage, versioned)]
    end
    FE -->|"https://<api fqdn>"| API
    API --> PG
    API --> REDIS
    API --> BLOB
    API --> KV
    PW --> PG
    PW --> REDIS
    PRW -->|owner DSN, DDL| PG
    API -->|encrypted email outbox| PG
    PW -.send.-> ACS[Azure Communication Services Email]
    MIG["migration job<br/>(manual trigger)"] --> PG
    BOOT["admin-bootstrap job<br/>(manual trigger)"] --> PG
```

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`)

| Parameter                        | Default                                                      | Meaning                                                                                                                                                                                                         |
| -------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `location`                       | `resourceGroup().location` (`uksouth` in the example params) | Azure region for every resource.                                                                                                                                                                                |
| `environment`                    | `production`                                                 | `development` or `production`; gates Multi-AZ/high-availability behavior on Postgres, Redis, and Container Apps scaling, plus Log Analytics retention.                                                          |
| `namePrefix`                     | `causeloop`                                                  | 3–12 characters (`@minLength(3)`/`@maxLength(12)`); combines with `environment` and a `uniqueString()` suffix to name every resource. Lowercase letters and digits are recommended, not enforced by validation. |
| `containerRegistryName`          | —                                                            | Existing ACR name.                                                                                                                                                                                              |
| `containerRegistryResourceGroup` | `resourceGroup().name`                                       | Resource group containing that ACR, if different from the deployment's own group.                                                                                                                               |
| `backendImage`                   | —                                                            | Backend image repository:tag, without registry hostname.                                                                                                                                                        |
| `frontendImage`                  | —                                                            | Frontend image repository:tag, without registry hostname.                                                                                                                                                       |
| `databaseOwnerPassword`          | — (`@secure()`)                                              | Postgres owner/migration role password.                                                                                                                                                                         |
| `appDatabasePassword`            | — (`@secure()`)                                              | Postgres `app_rw` role password.                                                                                                                                                                                |
| `initialAdminEmail`              | —                                                            | Email for the first idempotently bootstrapped onboarding administrator (min length 3).                                                                                                                          |
| `initialAdminName`               | —                                                            | Display name (1–200 characters).                                                                                                                                                                                |
| `initialAdminPassword`           | — (`@secure()`, min length 8)                                | Initial administrator password, injected only into the manual bootstrap job's Container Apps secret.                                                                                                            |
| `activateServices`               | `false`                                                      | Gates whether the four long-running Container Apps (api, product-worker, provisioner-worker, frontend) are created at all — see [Deploy procedure](#deploy-procedure).                                          |
| `databaseName`                   | `causeloop`                                                  | Postgres database name.                                                                                                                                                                                         |
| `databaseOwnerUsername`          | `causeloop_owner`                                            | Postgres owner-role username.                                                                                                                                                                                   |
| `postgresSku`                    | `Standard_D2ds_v5`                                           | Flexible Server SKU; tier is derived as `Burstable` if the SKU name starts with `Standard_B`, otherwise `GeneralPurpose`.                                                                                       |
| `backupRetentionDays`            | `14`                                                         | Postgres automated backup retention.                                                                                                                                                                            |
| `objectRetentionDays`            | `365`                                                        | Blob lifecycle policy: delete blobs under the `tenants/` prefix after this many days.                                                                                                                           |
| `communicationDataLocation`      | `Europe`                                                     | Azure Communication Services data-residency geography for the email service and communication service.                                                                                                          |

`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

<AccordionGroup>
  <Accordion title="Networking">
    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.
  </Accordion>

  <Accordion title="Data services">
    * `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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="Managed identities, custom role, and RBAC">
    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.
  </Accordion>

  <Accordion title="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):

    * `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.
  </Accordion>

  <Accordion title="Container Apps (conditional on activateServices)">
    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](/architecture/workers-and-jobs) for what `materialize_insights`, `send_email`, `train_tenant_model`, and `provision_tenant` actually do.
  </Accordion>

  <Accordion title="Diagnostics">
    `Microsoft.Insights/diagnosticSettings` on the Postgres server streams all log categories and all metrics to the Log Analytics workspace.
  </Accordion>
</AccordionGroup>

## Deploy procedure

<Steps>
  <Step title="Push immutable images">
    Push tagged frontend and backend images into the existing ACR referenced by `containerRegistryName` before the first deployment.
  </Step>

  <Step title="Set the three password environment variables">
    ```bash theme={null}
    export AZURE_DATABASE_OWNER_PASSWORD="..."
    export AZURE_APP_DATABASE_PASSWORD="..."
    export AZURE_INITIAL_ADMIN_PASSWORD="..."   # at least 8 characters, unique per environment
    ```
  </Step>

  <Step title="Deploy with activateServices=false">
    ```bash theme={null}
    az deployment group create \
      --resource-group YOUR_RESOURCE_GROUP \
      --template-file deploy/azure/main.bicep \
      --parameters deploy/azure/main.bicepparam \
      --parameters 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)`.
  </Step>

  <Step title="Start and verify the migration job">
    ```bash theme={null}
    az containerapp job start \
      --name causeloop-production-migration \
      --resource-group YOUR_RESOURCE_GROUP
    ```

    Require a successful execution before proceeding — check the job's execution history/logs in the portal or with `az containerapp job execution list`.
  </Step>

  <Step title="Start and verify the administrator-bootstrap job">
    ```bash theme={null}
    az containerapp job start \
      --name causeloop-production-admin-bootstrap \
      --resource-group YOUR_RESOURCE_GROUP
    ```

    Again, require a successful execution before proceeding.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Redeploy with activateServices=true">
    ```bash theme={null}
    az deployment group create \
      --resource-group YOUR_RESOURCE_GROUP \
      --template-file deploy/azure/main.bicep \
      --parameters deploy/azure/main.bicepparam \
      --parameters activateServices=true
    ```

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

  <Step title="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.
  </Step>

  <Step title="Run the public-path smoke check">
    ```bash theme={null}
    python3 scripts/smoke_product.py \
      --frontend-url "$(az deployment group show \
        --resource-group YOUR_RESOURCE_GROUP \
        --name YOUR_DEPLOYMENT_NAME \
        --query properties.outputs.frontendUrl.value \
        --output tsv)"
    ```

    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](/deployment/aws).
  </Step>
</Steps>

## How this differs from AWS

|                               | AWS                                                                                                                    | Azure                                                                                                                                                             |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Compute                       | ECS/Fargate services + one-off tasks                                                                                   | Container Apps (`if (activateServices)`-guarded) + manual-trigger Container Apps Jobs                                                                             |
| Internal API reachability     | Cloud Map private DNS service discovery (`api.<namespace>`, where `<namespace>` is `causeloop-<environment>.internal`) | Internal Container Apps ingress; frontend reads the API's FQDN directly from the deployment                                                                       |
| Credential encryption         | AWS KMS (`aws-kms` provider)                                                                                           | Azure Key Vault RSA key (`azure-key-vault` provider)                                                                                                              |
| Secret delivery to containers | ECS `secrets` block reading Secrets Manager ARNs                                                                       | Container Apps `secrets` with `keyVaultUrl` + `identity` (or plain values for the manual jobs)                                                                    |
| Redis                         | ElastiCache Redis (classic engine, AUTH token, TLS)                                                                    | Azure Managed Redis (`Balanced_B0`, `EnterpriseCluster` policy, `Encrypted` client protocol) — chosen specifically because Azure Cache for Redis is being retired |
| Object storage                | S3, versioned, KMS SSE, lifecycle rule on the whole bucket                                                             | Blob Storage, versioned, identity-only access (no shared keys), lifecycle rule scoped to the `tenants/` prefix                                                    |
| Image registry access         | IAM execution-role policy attachment (`AmazonECSTaskExecutionRolePolicy`)                                              | Explicit `AcrPull` role assignment per identity via `acr-pull.bicep`                                                                                              |
| Email provider                | Amazon SES, sender identity created but verification/production access is manual                                       | Azure Communication Services Email with an Azure-managed sender domain (no manual domain verification step)                                                       |
| Frontend privilege            | Dedicated `execution_frontend`/`frontend` IAM roles, both with no application secrets                                  | Dedicated `frontendIdentity` with no storage, Key Vault, or database-secret role                                                                                  |
| Migration/bootstrap trigger   | `run-migration.sh` / `run-admin-bootstrap.sh` (`aws ecs run-task` + `wait tasks-stopped`)                              | `az containerapp job start` (manual-trigger jobs)                                                                                                                 |

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.

## Related

* [AWS deployment](/deployment/aws)
* [Cloud environments](/deployment/environments)
* [Migrations](/deployment/migrations)
* [Workers and jobs](/architecture/workers-and-jobs)
