Skip to content

Azure

How indx runs on Azure: one Terraform stack, one dispatch-only workflow, one Container App serving its hostname directly on a managed certificate, and the Azure OpenAI lanes as variables.

One Container App runs the image on a Consumption environment, its ingress is the public origin, and one DNS-only CNAME the operator creates by hand is the public hostname. Everything here is infra/azure/ and .github/workflows/deploy-azure-prod.yml, a dispatch-only workflow shaped like the AWS one and sharing no variable, secret, identity or state with it by name (ADR-0054). The same stack can still be applied by hand.

The container has no database and no bucket. Its weights are in the image and read offline. Azure OpenAI is reached through LiteLLM on an API key held as a Container App secret, and only when the switch below is on; otherwise the LLM lanes report themselves unavailable and no secret exists.

The container takes bytes inline and nothing else. INDX_URI_SCHEMES=none disables every URI source, so no caller can name a file on the container’s disk or a URL inside the subscription, and INDX_LOADER_FILE_ROOTS is closed to the samples directory as a second layer (ADR-0042) — the three fixed environment lines the AWS task carries beside AWS_REGION.

Stack Applied by Holds
infra/azure/terraform the workflow’s deploy, or an operator by hand the resource group, ACR, the app’s identity, Log Analytics, the Consumption environment, the Container App, the budget

State lives in an Azure Storage container the operator creates first, named by a partial backend, so nothing about it is in the repository:

resource_group_name = "indx-everything-prod-tfstate"
storage_account_name = "indxeverythingtfstate"
container_name = "tfstate"
key = "indx-everything/azure/terraform.tfstate"

That state holds nothing secret: the certificate is Azure’s, issued and renewed by Azure, and its key never leaves the platform. The stack knows no DNS provider at all. The hostname and its certificate are bound by the workflow’s deploy with az containerapp hostname bind, once the two records the operator creates by hand resolve. just infra::terraform::validate formats, initializes without a backend and validates this stack with no credentials, and .github/workflows/infra.yml runs it on every pull request.

There is no perimeter. The record is DNS-only, so a visitor reaches the ingress directly, and the ingress admits everyone: Container Apps issues and renews a managed certificate only for a CNAME that points straight at the app’s FQDN, and only when DigiCert can reach the app over HTTP, which a proxy in between or an allow-list would forbid. What refuses a Host this app is not bound to is Container Apps’ own host-based routing, which serves the custom domain and the app’s default FQDN and nothing else. The other two clouds sit behind Cloudflare’s proxy; this one does not, because the hostname is two labels below the zone and Cloudflare’s free certificate covers one, and because the Origin CA certificate that shape needs takes a Cloudflare token with SSL and Certificates / Edit, which the operator declined, along with any token at all: the two records are made by hand (ADR-0064). The API is unauthenticated (ADR-0039), and Cloudflare Access is not available on a DNS-only record: authentication, when it comes, is the app’s or Azure’s.

  • A subscription the operator owns, and az. The stack takes the subscription as a plain variable and asserts nothing about it.

  • Terraform >= 1.9, < 2.0.

  • A state account and container, created once:

    Terminal window
    az group create --name indx-everything-prod-tfstate --location japaneast
    az storage account create --name indxeverythingtfstate \
    --resource-group indx-everything-prod-tfstate --location japaneast \
    --sku Standard_LRS --encryption-services blob
    az storage container create --name tfstate \
    --account-name indxeverythingtfstate --auth-mode login
  • Write access to the zone’s DNS, in a dashboard. No credential is read: the two records the hostname needs are created by hand, and their values exist only once the app does, so the first deployment prints them and stops.

  • A registry name that is free. The registry is project_name stripped of its separators, because an ACR name is a DNS label in azurecr.ioindxeverythingprod by default — and that name is globally unique across Azure. If another tenant holds it the apply fails with a name-availability error; change project_name.

The Azure OpenAI account itself is not created here. It carries a quota request, a region choice and a data-residency decision that belong to the subscription’s owner, not to a template (ADR-0053).

The API is public, and every call to an Azure OpenAI-backed lane is billed to the subscription, so those lanes are off unless enable_azure_openai is true. Off means the container carries no model variable, so llm, ner-llm and enrich-llm are not advertised and a request naming one is refused with a 422, and generic-vlm and hosted-text report themselves unavailable. A deployment named while the switch is off fails terraform plan rather than being ignored, and so does the switch turned on without an endpoint and a key, or without an address to notify.

On, these variables become these lines on the container:

Variable Becomes
azure_openai_endpoint AZURE_API_BASE
azure_openai_api_version AZURE_API_VERSION, 2024-10-21 by default
azure_openai_api_key AZURE_API_KEY, as a Container App secret rather than an environment value, so it is not readable from the revision’s template
azure_openai_llm_deployment INDX_CLASSIFIER_LLM_MODEL, INDX_NER_LLM_MODEL, INDX_ENRICH_LLM_MODEL and INDX_VLM_MODEL, each azure/<deployment>
azure_openai_embed_deployment INDX_EMBED_MODEL, azure/<deployment>
azure_openai_embed_dimension INDX_EMBED_DIMENSION

The dimension is declared and not discovered: a hosted embedding space with no dimension is not advertised at all, silently, so the plan refuses an embedding deployment without one.

The switch also creates a monthly budget on the resource group (azure_openai_monthly_budget_usd, 50 by default), which notifies alert_emails at 80 percent forecast and 100 percent actual. It is a horn and not a brake: an Azure budget cannot detach a role the way AWS Budgets can (ADR-0040 is AWS-only), so the operator turning the switch off is what stops the spend, and the overshoot is whatever is billed between the alert and that apply. Its scope is this resource group, not the Azure OpenAI account: an account created in this group is covered, one created anywhere else is not, and the number then bounds the compute alone. budget_start_date must be the first of a month, because Azure accepts nothing else and never moves it once the budget exists.

The workflow authenticates as two Entra application registrations, one for deploy and one for plan. Each trusts GitHub’s OIDC issuer for one Environment and nothing else. Create them once, by hand:

Terminal window
subscription=<subscription id>
deploy=$(az ad app create --display-name indx-everything-prod-azure-deploy --query appId -o tsv)
plan=$(az ad app create --display-name indx-everything-prod-azure-plan --query appId -o tsv)
az ad sp create --id "$deploy"
az ad sp create --id "$plan"
az ad app federated-credential create --id "$deploy" --parameters '{
"name": "github-production-azure",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:INDXDev@209891251/indx-everything@1301159823:environment:production-azure",
"audiences": ["api://AzureADTokenExchange"]
}'
az ad app federated-credential create --id "$plan" --parameters '{
"name": "github-production-azure-plan",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:INDXDev@209891251/indx-everything@1301159823:environment:production-azure-plan",
"audiences": ["api://AzureADTokenExchange"]
}'

The subject names the repository and its owner by their immutable IDs rather than by INDXDev/indx-everything, so a workflow outside those Environments cannot exchange a token for one of these identities, and a repository rename cannot transfer the trust. The audience api://AzureADTokenExchange is the only one Entra accepts for this exchange.

Then the roles:

Terminal window
state="/subscriptions/$subscription/resourceGroups/indx-everything-prod-tfstate/providers/Microsoft.Storage/storageAccounts/indxeverythingtfstate"
az role assignment create --assignee "$deploy" --role Contributor --scope "/subscriptions/$subscription"
az role assignment create --assignee "$deploy" --role "Role Based Access Control Administrator" --scope "/subscriptions/$subscription"
az role assignment create --assignee "$plan" --role Reader --scope "/subscriptions/$subscription"
for app in "$deploy" "$plan"; do
az role assignment create --assignee "$app" --role "Storage Blob Data Contributor" --scope "$state"
done

And, once the first deploy has created the resource group, one more on the plan application:

Terminal window
az role assignment create --assignee "$plan" --role "Container Apps Operator" \
--scope "/subscriptions/$subscription/resourceGroups/indx-everything-prod"

The scope is the subscription because the stack creates the resource group itself, and there is nothing narrower to grant until it exists. The second role on the deploy application is there because the stack also writes the AcrPull assignment that lets the app pull its own image, and writing a role assignment is not something Contributor may do. Reader is everything a plan needs of the subscription until the Container App exists: refreshing it calls listSecrets, which is an action and not a read, so Reader alone fails the second plan with a 403. Container Apps Operator is the built-in role that carries the app’s actions without its writes, which also lets that identity restart the app; scoped to the resource group, it is granted after the first deploy because there is no group before it. Both applications write to the state account, because the backend takes a lease on the state blob even to plan. The stack asserts nothing about any of these roles, and a narrower policy, scoped to this resource group and to the addresses this stack names, is the operator’s to write.

Then two Environments. production-azure-plan has no reviewer and a deployment branch rule of main only. production-azure has a required reviewer and the same rule. That reviewer is the approval for every mutation.

Then the repository variables and secrets, exactly the names the workflow reads:

Name Kind Value
AZURE_PROD_DOMAIN_NAME variable, optional the public hostname, any name under indx.jp; azure.indx.jp by default
AZURE_PROD_SUBSCRIPTION_ID variable the subscription every resource is created in; no default
AZURE_PROD_TENANT_ID variable the tenant the two applications live in; no default
AZURE_PROD_CLIENT_ID variable the deploy application’s ID; no default
AZURE_PROD_PLAN_CLIENT_ID variable the plan application’s ID; no default
AZURE_PROD_PROJECT_NAME variable, optional indx-everything-prod by default
AZURE_PROD_LOCATION variable, optional japaneast by default
AZURE_PROD_TF_BACKEND_RESOURCE_GROUP variable, optional indx-everything-prod-tfstate by default
AZURE_PROD_TF_BACKEND_STORAGE_ACCOUNT variable, optional indxeverythingtfstate by default
AZURE_PROD_TF_BACKEND_CONTAINER variable, optional tfstate by default
AZURE_PROD_TF_BACKEND_KEY variable, optional indx-everything/azure/terraform.tfstate by default
AZURE_PROD_MIN_INSTANCES variable, optional 0 by default, and 0 or 1
AZURE_PROD_OPENAI_ENABLED variable, optional true turns the Azure OpenAI lanes on; false by default
AZURE_PROD_OPENAI_ENDPOINT variable, required with the switch e.g. https://<account>.openai.azure.com; no default
AZURE_PROD_OPENAI_API_VERSION variable, optional 2024-10-21 by default
AZURE_PROD_OPENAI_LLM_DEPLOYMENT variable, optional the deployment behind the classifier, NER, enrichment and VLM lanes, e.g. gpt-4o; no default
AZURE_PROD_OPENAI_EMBED_DEPLOYMENT variable, optional the deployment behind the hosted embedding space, e.g. text-embedding-3-large; no default
AZURE_PROD_OPENAI_EMBED_DIMENSION variable, required with the embedding deployment the model’s vector size, e.g. 3072; 0 by default
AZURE_PROD_OPENAI_MONTHLY_BUDGET_USD variable, optional 50 by default
AZURE_PROD_ALERT_EMAILS variable, required with the switch a JSON list, e.g. ["ops@example.com"]; [] by default
AZURE_PROD_BUDGET_START_DATE variable, optional the first of a month, 2026-10-01T00:00:00Z by default
AZURE_PROD_OPENAI_API_KEY secret, required with the switch the account’s API key; no default

No Cloudflare secret: this workflow writes no DNS record.

AZURE_PROD_DOMAIN_NAME is any name under indx.jp, so the hostname need not say which cloud serves it. The three stacks write records in one zone, so every job refuses a name equal to PROD_DOMAIN_NAME or GCP_PROD_DOMAIN_NAME, the other two clouds’ hostnames, before anything else runs. That is the one check that keeps two workflows from naming one record. deploy runs only when the operator has typed the name into confirm.

Every operation is Deploy to Azure (production) on main, dispatched by hand. No push deploys. Every run starts with the static validation job, which holds no production credentials, then the one job the action names.

action Environment What happens
plan production-azure-plan A read-only plan of the stack. Its summary is the artifact a deploy must name.
deploy production-azure Applies the registry alone, builds and pushes the image, re-plans and refuses when the plan differs from the approved run, applies, waits for the revision, binds the managed certificate, runs the public smoke, the @deployed scenarios and the web app’s specs.

Dispatch on main. No reviewer, so the approval stays between this plan and the mutation, and the identity is the read-only one. The job initializes the backend from the four AZURE_PROD_TF_BACKEND_ variables, plans with the image reference <registry>.azurecr.io/indx-everything:sha-<commit>, and uploads the summary as azure-prod-plan-summary-<run id>. That summary is the resource addresses and actions plus the commit, ref and repository it was planned from, and nothing secret, so it is what a reviewer reads.

Note the run ID. It is the plan_run_id a deploy must cite, and it is valid for that commit only.

Dispatch with confirm set to the hostname and plan_run_id set to that plan’s run. The reviewer approves the production-azure Environment, then, in order:

  1. Guards the ref, the hostname, the confirmation and the run ID, and downloads the approved summary from that run.
  2. Applies azurerm_container_registry.main alone. The image cannot be pushed to a registry that does not exist, and the Container App cannot start without the image; -target pulls the resource group in with it.
  3. Frees runner disk, ensures the weights image, builds Dockerfile for linux/amd64 and pushes sha-<commit>, skipped when that tag is already in the registry.
  4. Plans again and compares it with the approved summary. The provenance must be this repository, main and this commit, and the change set must be identical apart from the registry and the resource group the step above created. Any other difference fails the run before anything is applied.
  5. Applies that plan.
  6. Waits up to 900 seconds for the newest active revision to report Provisioned and Healthy.
  7. Binds the managed certificate. First it resolves the hostname and its asuid TXT and compares them with the app’s FQDN and verification ID; when they differ, the step writes the two records to create into the job summary and fails, which is where the first deploy ends. When they match, az containerapp hostname add adds the hostname to the app, once, because a managed certificate is refused for a hostname the app does not carry, and az containerapp hostname bind with --validation-method CNAME finds or issues the certificate and binds it. It runs after the revision because DigiCert validates the name over HTTP against the running app, and it is a no-op once bound.
  8. Runs the public smoke, scripts/infra/deployed-smoke.sh, which drives the hostname, asserts each hosted lane is present exactly when its model is set, asserts a file: URI is refused, and asserts the ingress FQDN answers a direct client with a 200, because it is the public origin.
  9. Runs just test::bdd::deployed against the public host.
  10. Runs just frontend::e2e-deployed against the public host, so a deploy whose UI cannot run the demo is a red workflow and not a green one.

The smoke’s first request can fail while the replica wakes, so it retries /health for up to fifteen minutes rather than failing on the first answer. That is the scale-to-zero trade below, not a flake.

A redeploy is image_tag, set to the sha-<commit> of an image the registry already holds, on the plan and again on the deploy. That is the rollback. The workflow refuses an image_tag the registry does not hold, so a named tag can only be a redeploy of something that was built once. There is no cutover action, because the record is the operator’s and is never moved, and no rollback job, because a rollback is that same deploy.

The first deploy is two cycles. The app’s FQDN and its verification ID exist only once the app does, so the first run applies the stack, prints the CNAME and the TXT to create in its summary and fails at the bind. Create them, DNS-only, then run plan and deploy again: that second run binds the certificate and runs the three suites.

This is the same stack the workflow applies, for an operator without the GitHub setup above.

Sign in:

Terminal window
az login
az account set --subscription <subscription id>

Write the backend and the variables from the two examples, then initialize:

Terminal window
cd infra/azure/terraform
cp backend.hcl.example backend.hcl
cp terraform.tfvars.example terraform.tfvars
terraform init -backend-config=backend.hcl

The registry has to exist before the image can be pushed to it, and the Container App cannot start without the image, so the registry is applied alone first. -target pulls in the resource group it needs:

Terminal window
terraform apply -target=azurerm_container_registry.main

Build the image and push it under the tag terraform.tfvars names:

Terminal window
just infra::image::build
tag="sha-$(git rev-parse HEAD)"
docker tag indx-everything:local "indxeverythingprod.azurecr.io/indx-everything:$tag"
az acr login --name indxeverythingprod
docker push "indxeverythingprod.azurecr.io/indx-everything:$tag"

Apply the rest with the switch off, so nothing is billed to a model, and read the two values the records need:

Terminal window
terraform apply
terraform output -raw app_fqdn
terraform output -raw custom_domain_verification_id

Create the two records in the zone, DNS-only: a CNAME at the hostname whose content is the FQDN, and a TXT at asuid.<hostname> whose content is the verification ID. Then, once the revision is healthy and the records resolve, bind the managed certificate:

Terminal window
az containerapp hostname add \
--name "$(terraform output -raw app_name)" \
--resource-group "$(terraform output -raw resource_group)" \
--hostname <hostname>
az containerapp hostname bind \
--name "$(terraform output -raw app_name)" \
--resource-group "$(terraform output -raw resource_group)" \
--environment "$(terraform output -raw environment_name)" \
--hostname <hostname> --validation-method CNAME

The add is once: it refuses a hostname the app already carries. The bind is what makes Azure issue the certificate, and it waits for the issuance. Then check the host, in this order:

Terminal window
curl -sf https://<hostname>/health | jq -e '.status == "ok"'
curl -sf https://<hostname>/ | grep -q '<html'
curl -sf https://<hostname>/en/deploy/ | grep -q '<html'
curl -s -o /dev/null -w '%{http_code}\n' "https://$(terraform output -raw app_fqdn)/health"

The last one is a 200: the ingress FQDN is the origin, and it is public. Then warm the container once and run the two suites that drive a real host:

Terminal window
curl -sf https://<hostname>/health >/dev/null
just test::bdd::deployed https://<hostname>
just frontend::e2e-deployed https://<hostname>

The warm-up is not ceremony; see below. Last, turn the switch on and apply again — the key is passed in the environment, never from a file in the repository:

Terminal window
TF_VAR_azure_openai_api_key=<key> terraform apply

with enable_azure_openai = true, the endpoint, the deployments, the dimension and alert_emails set in terraform.tfvars. Flipping the switch either way is a new revision, because the container’s environment changes.

min_instances is 0 by default and validated to 0 or 1; max_replicas is 1 either way. Zero costs nothing while idle. One keeps a replica warm and is billed for it around the clock.

Zero is not free of consequence. The cold start is an image pull plus an engine import, which the startup probe allows 300 seconds for, while the Container Apps ingress caps a request at 240. The first request after an idle period is therefore expected to fail at the ingress, and a request a minute later to succeed against the replica it woke. A scripted client must allow for that first failure, or min_instances = 1 is the setting for anyone who cannot.

The cold start has not been measured on a real deployment yet; fill the number in here after the first one.

  • The record must stay DNS-only. Azure issues and renews the managed certificate only for a CNAME that resolves straight to the app’s FQDN, and only while DigiCert can reach the app. Turning the proxy on, or adding an ingress allow-list, breaks the next renewal, not the next request, so the failure arrives months later.
  • The bind step reads DNS with the runner’s resolver. A record created a moment before the run can still be absent there, and the step then fails with the records printed as if they did not exist. Wait a minute and dispatch again; nothing to edit.
  • The hostname is bound by the CLI, not by Terraform. The stack holds no custom-domain resource and no DNS record; the provider has no resource for a managed certificate, and the records are the operator’s. A hostname that answers with the environment’s default certificate is a bind that has not run yet.
  • The role assignment can be slower than the apply. The Container App is ordered after the AcrPull grant, but an Entra role assignment takes time to propagate, so a first revision can still fail its pull. Re-run the apply, or restart the revision.
  • Consumption sells CPU and memory in fixed pairs, and two ceilings. 8 GiB — the floor the ONNX graphs, the fastembed models and the OCR engine need together once resident — is what forces container_cpu to 4. A Consumption-only environment stops at the 2 vCPU / 4 GiB pair, which the first deploy learned from a 400; the Consumption workload profile goes to 4 / 8 and still scales to zero, so the environment declares that profile. An environment created without one cannot gain it later, only be recreated. Dropping to 2 / 4 means dropping both numbers, and only after reading the revision’s working set under real traffic.
  • Every scale-out from zero pulls the whole image. The image carries the weights, so a cold start moves several gigabytes before the first line of Python runs. That is the cost min_instances = 1 buys out.
  • The budget watches the resource group, not the model. Read its scope above before trusting the number: unless the Azure OpenAI account lives in this resource group, the budget measures the Container App’s own cost.