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.
Demonstrated on 2026-09-18 at everything.az.indx.jp: the deploy run is green through the public smoke, the @deployed scenarios and the web app’s specs.
Topology
Section titled “Topology”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.
The stack
Section titled “The stack”| 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.
Prerequisites
Section titled “Prerequisites”-
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 japaneastaz storage account create --name indxeverythingtfstate \--resource-group indx-everything-prod-tfstate --location japaneast \--sku Standard_LRS --encryption-services blobaz 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_namestripped of its separators, because an ACR name is a DNS label inazurecr.io—indxeverythingprodby default — and that name is globally unique across Azure. If another tenant holds it the apply fails with a name-availability error; changeproject_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 Azure OpenAI switch
Section titled “The Azure OpenAI switch”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.
GitHub, once
Section titled “GitHub, once”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:
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:
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"doneAnd, once the first deploy has created the resource group, one more on the plan application:
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.
Operating
Section titled “Operating”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.
deploy
Section titled “deploy”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:
- Guards the ref, the hostname, the confirmation and the run ID, and downloads the approved summary from that run.
- Applies
azurerm_container_registry.mainalone. The image cannot be pushed to a registry that does not exist, and the Container App cannot start without the image;-targetpulls the resource group in with it. - Frees runner disk, ensures the weights image, builds
Dockerfileforlinux/amd64and pushessha-<commit>, skipped when that tag is already in the registry. - Plans again and compares it with the approved summary. The provenance must be this repository,
mainand 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. - Applies that plan.
- Waits up to 900 seconds for the newest active revision to report
ProvisionedandHealthy. - Binds the managed certificate. First it resolves the hostname and its
asuidTXT 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 firstdeployends. When they match,az containerapp hostname addadds the hostname to the app, once, because a managed certificate is refused for a hostname the app does not carry, andaz containerapp hostname bindwith--validation-method CNAMEfinds 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. - 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 afile:URI is refused, and asserts the ingress FQDN answers a direct client with a 200, because it is the public origin. - Runs
just test::bdd::deployedagainst the public host. - Runs
just frontend::e2e-deployedagainst 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.
Deploying by hand
Section titled “Deploying by hand”This is the same stack the workflow applies, for an operator without the GitHub setup above.
Sign in:
az loginaz account set --subscription <subscription id>Write the backend and the variables from the two examples, then initialize:
cd infra/azure/terraformcp backend.hcl.example backend.hclcp terraform.tfvars.example terraform.tfvarsterraform init -backend-config=backend.hclThe 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:
terraform apply -target=azurerm_container_registry.mainBuild the image and push it under the tag terraform.tfvars names:
just infra::image::buildtag="sha-$(git rev-parse HEAD)"docker tag indx-everything:local "indxeverythingprod.azurecr.io/indx-everything:$tag"az acr login --name indxeverythingproddocker 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:
terraform applyterraform output -raw app_fqdnterraform output -raw custom_domain_verification_idCreate 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:
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 CNAMEThe 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:
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:
curl -sf https://<hostname>/health >/dev/nulljust 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:
TF_VAR_azure_openai_api_key=<key> terraform applywith 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.
Scale to zero
Section titled “Scale to zero”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.
Gotchas
Section titled “Gotchas”- 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
AcrPullgrant, 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_cputo 4. A Consumption-only environment stops at the 2 vCPU / 4 GiB pair, which the first deploy learned from a400; 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 = 1buys 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.