Skip to content

How-to 01 infrastructure

This builds the infrastructure for a small production web application on Azure: a static site, an HTTP API, a document database, object storage, and a secret store. Everything is defined in Terraform, deployed from CI, and holds no stored credentials anywhere — not in a file, not in a variable, not in a vault.

Part 2 deploys the application onto it.

What you'll have at the end

Resource Purpose
Static Web App The site itself, served from Azure's global edge
Function App (Flex Consumption) The HTTP API and scheduled jobs
Cosmos DB (serverless, NoSQL) The database
Two storage accounts Uploaded content, and the Functions host's own state
Key Vault Runtime secrets, resolved by managed identity
VNet + one delegated subnet The only network path into the data stores
Log Analytics + Application Insights Telemetry, in a separate subscription
Two managed identities One for Terraform, one for the deploy workflows

Roughly 130 resources once containers and role assignments are counted.

What it costs

A USD 150/month ceiling, and that number does most of the architectural work below. It is worth naming your ceiling before your services, because a budget you cannot argue with is a budget that makes decisions for you.

Steady-state, with no traffic, this shape costs a few dollars: Cosmos serverless bills per request unit, Flex Consumption bills per execution, storage bills for what you store, and Static Web Apps Standard is a flat ~$9. There is no idle compute anywhere, which is the entire point.


Why these services

The section that matters. Every choice below names the alternative it beat and the constraint that decided it.

Flex Consumption, not Elastic Premium or classic Consumption

Elastic Premium bills for always-warm instances whether or not anything runs. One instance is roughly the entire monthly budget. Rejected on cost.

Classic Consumption is cheap but cannot join a VNet, and VNet integration is what lets every data store deny public traffic and admit exactly one subnet. Rejected on architecture — it would have forced the databases open.

Flex Consumption scales to zero, bills per execution, and supports VNet integration. It is the only one of the three that satisfies both constraints.

Two settings are worth setting explicitly rather than inheriting:

instance_memory_in_mb  = 2048   # per-app, not per-function
maximum_instance_count = 20     # a public endpoint with no ceiling is a bill with no ceiling
# always-ready instances deliberately not configured — defaults to zero.
# One always-ready 2048 MB instance is ~$20/month whether or not it executes.

Cosmos DB serverless, single region, session consistency

Provisioned throughput has a floor of 400 RU/s per container. With dozens of containers that floor alone exceeds the budget several times over. Rejected on cost.

Serverless bills per request unit consumed, with no floor. For a site whose traffic is bursty and low, that is the difference between $10 and $400.

Single region and zone_redundant = false are consistent with that decision rather than a compromise: a serverless single-region account has already accepted the failure mode zone redundancy protects against, so paying for redundancy on one axis while ignoring the other buys nothing.

Session consistency because it matches what the previous datastore provided — consistency is a semantic the application depends on, so changing it during a migration means changing application behaviour at the same time as infrastructure. Do one at a time.

Two storage accounts, not one and not four

One would mean the Functions runtime's own state — host locks, deployment packages — sharing an account with user-uploaded content. Different lifecycles, different blast radius: replacing the Functions host account takes the API down until redeploy, and it should not be able to take your users' images with it.

Four (one per content type, the common enterprise shape) multiplies the firewall rules, role assignments and network rules by four for no isolation you actually need at this size.

Two, split on lifecycle rather than on content type.

Static Web Apps, not Blob static hosting or App Service

Blob static website hosting has no built-in TLS for custom domains, no staging environments, and no SPA routing fallback. You would rebuild all three.

App Service means paying for a plan to serve files.

Static Web Apps Standard gives global edge distribution, free managed certificates, PR preview environments and SPA routing for ~$9/month.

Key Vault with managed identity, not app settings

Secrets could sit directly in Function App settings. They would then be readable by anyone with Contributor on the resource group, appear in ARM template exports, and be invisible to any audit.

Key Vault references (@Microsoft.KeyVault(SecretUri=…)) resolve at runtime through the app's own managed identity. The secret value never enters Terraform state, never enters an app setting, and access is a role assignment you can revoke.

The consequence to plan for: the vault must be reachable by the app. See the service-endpoint trap in step 5 — it is the single most common way this design fails silently.

OIDC federation, not service principal secrets

A service principal client secret is a long-lived credential that must be stored, rotated and eventually leaked. OIDC federation exchanges a short-lived token, issued per run, for Azure access. Nothing is stored.

This applies twice, to two different identities that are easy to confuse:

Terraform → Azure GitHub Actions → Azure
Created by A bootstrap script, by hand, once Terraform
Identity id-plat-terraform-prod-cus-01 id-app-github-deploy-prod-cus-01
Issuer app.terraform.io token.actions.githubusercontent.com
Exists Before anything else After the first successful apply

Managed identity, not app registration

Both identities are user-assigned managed identities, and this is not stylistic. Creating an app registration requires Application Administrator in Entra ID, and Azure Owner does not grant that — Azure RBAC and Entra directory roles are separate permission planes.

A managed identity is an ordinary Azure resource. Entra supports federating one to an arbitrary external issuer. So an Azure Owner can build the entire credential chain with no directory role at all, which is usually the difference between deploying today and filing a ticket.


Prerequisites

  • An Azure subscription where you hold Owner (needed to create role assignments, not just resources).
  • Azure CLI, signed in: az login.
  • Terraform ≥ 1.5, and an HCP Terraform organisation, project and workspace.
  • A GitHub repository.
  • Nothing pre-created in the portal. If you have been clicking, start from an empty resource group — Terraform importing a hand-made resource is a much worse first day than creating it.

Check region availability before you write any configuration. This is the step everyone skips and it costs the most:

# 1. Does the region support Flex Consumption?
az functionapp list-flexconsumption-locations --query "sort_by(@, &name)[].name" -o tsv

# 2. Is Cosmos deployable in the region at all? (ARM's answer)
az provider show --namespace Microsoft.DocumentDB \
  --query "resourceTypes[?resourceType=='databaseAccounts'].locations" -o tsv

# 3. Is YOUR subscription cleared for Cosmos there? (Cosmos's answer — different question)
az cosmosdb locations list \
  --query "[?properties.isSubscriptionRegionAccessAllowedForRegular].name" -o tsv

# 4. Is Static Web Apps offered there? Only five regions:
#    centralus, eastus2, westus2, westeurope, eastasia

Checks 2 and 3 answer different questions and routinely disagree. A region can be ARM-deployable while your subscription has no Cosmos access, and vice versa. Both must pass.

Check your globally-unique names, too. Storage accounts, Key Vaults, Cosmos accounts and Function Apps live in a namespace shared with every other Azure customer:

az storage account check-name --name stappprodcus01
az keyvault check-name --name kv-app-prod-cus-01
az cosmosdb check-name-exists --name cosmos-app-prod-cus

Finding out a name is taken during an apply is considerably worse than finding out in a terminal.


The steps

1. Bootstrap the Terraform identity

Terraform cannot create the credential Terraform authenticates with. This step exists to break that circle, runs once in the life of a subscription, and is the only manual step in the whole deployment.

Create a user-assigned managed identity, federate it to HCP Terraform, and grant it what it needs:

RG=rg-mgmt-boot-prod-cus
ID=id-plat-terraform-prod-cus-01
LOC=centralus

az group create -n $RG -l $LOC
az identity create -n $ID -g $RG -l $LOC

Now two federated credentials, not one. HCP Terraform stamps the run phase into the token subject, and Entra matches subjects as exact, case-sensitive strings with no wildcards:

for PHASE in plan apply; do
  az identity federated-credential create \
    --name "tfc-$PHASE" --identity-name $ID --resource-group $RG \
    --issuer "https://app.terraform.io" \
    --audiences "api://AzureADTokenExchange" \
    --subject "organization:acme:project:Platform:workspace:app-azure:run_phase:$PHASE"
done

Configure only plan and every run plans beautifully while every apply fails at authentication — which reads as a permissions problem and is not one.

Grant it Contributor and Role Based Access Control Administrator on every target subscription. Contributor alone cannot create the role assignments your configuration declares; RBAC Administrator cannot grant Owner, so the identity cannot escalate itself:

OID=$(az identity show -n $ID -g $RG --query principalId -o tsv)
for ROLE in "Contributor" "Role Based Access Control Administrator"; do
  az role assignment create --assignee-object-id $OID \
    --assignee-principal-type ServicePrincipal \
    --role "$ROLE" --scope "/subscriptions/<subscription-id>"
done

Keep this identity out of Terraform state, deliberately. If Terraform managed the credential it authenticates with, one bad destroy would lock the workspace out of the subscription with no way back in except re-running this by hand.

Verify: az identity federated-credential list --identity-name $ID -g $RG returns two credentials.

2. Set the workspace variables

In HCP Terraform, as environment variables — not Terraform variables. The ARM_* and TFC_AZURE_* names are read from the process environment, and set in the wrong category they are silently ignored while the run fails claiming no credentials were supplied.

Name Value
TFC_AZURE_PROVIDER_AUTH true
TFC_AZURE_RUN_CLIENT_ID client id of id-plat-terraform-prod-cus-01
ARM_TENANT_ID <tenant-id>
ARM_SUBSCRIPTION_ID <subscription-id>

Then your configuration's own variables — subscription ids, domain, alert email — as Terraform variables, sensitive where they are credentials.

Verify: a speculative terraform plan. It should reach Azure and propose creating resources. If it fails with AADSTS70021, the federated subject does not match — compare it character by character against step 1.

3. Declare the provider with no credentials in it

provider "azurerm" {
  features {}
  subscription_id                 = var.subscription_app
  resource_provider_registrations = "none"
  resource_providers_to_register  = var.azure_resource_providers
}

There is no client_id and no secret, and that absence is the configuration. HCP Terraform injects ARM_CLIENT_ID, ARM_OIDC_TOKEN and ARM_USE_OIDC per run phase; the provider picks them up with no HCL.

resource_provider_registrations = "none" is a v4→v5 change worth knowing. Older provider versions registered ~60 resource providers at startup. On a subscription nobody has deployed to, turning that off surfaces at apply time as MissingSubscriptionRegistration, which does not say what to do about it. List exactly what you need instead:

variable "azure_resource_providers" {
  type = list(string)
  default = [
    "Microsoft.App",                 # Flex Consumption subnet delegation
    "Microsoft.DocumentDB",
    "Microsoft.Insights",
    "Microsoft.KeyVault",
    "Microsoft.ManagedIdentity",
    "Microsoft.Network",
    "Microsoft.OperationalInsights",
    "Microsoft.Storage",
    "Microsoft.Web",
  ]
}

4. Name everything before you create anything

Azure resource names are immutable. So are regions. There is no rename — only destroy and recreate. Decide the convention now, while doing so is free.

Microsoft's Cloud Adoption Framework gives the shape:

<resource-abbreviation>-<workload>-<environment>-<region>[-<instance>]

The instance number is assigned per resource type, not to everything. CAF's own tables give one to function apps, web apps, storage accounts, virtual networks, subnets, NSGs and managed identities — and withhold it from resource groups, Cosmos accounts and route tables.

Resist the tempting rule "add a number only when there is more than one". It makes the name depend on a fact that changes after the name is fixed, and since names are immutable the first instance can never be corrected.

Storage accounts take no hyphens and cap at 24 characters, so the number runs on unseparated — stappprodcus01. That is CAF's own shape, not a compromise.

5. Close the network — and avoid the silent trap

Every data store denies public traffic and admits the Functions subnet:

resource "azurerm_subnet" "functions" {
  name                 = "snet-app-func-prod-cus-01"
  address_prefixes     = ["10.40.0.0/24"]

  # REQUIRED. A VNet rule on Key Vault, Cosmos or Storage is INERT without the
  # matching service endpoint — the rule exists, looks correct in the portal,
  # and denies the Function App along with everyone else.
  dynamic "service_endpoint" {
    for_each = toset(["Microsoft.KeyVault", "Microsoft.AzureCosmosDB", "Microsoft.Storage"])
    content { service = service_endpoint.value }
  }

  delegation {
    name = "flex-consumption"
    service_delegation {
      name    = "Microsoft.App/environments"
      # join/action is what Azure actually assigns for this delegation.
      # Name anything else and the plan never converges: Terraform writes your
      # value, Azure replaces it, the next plan proposes the same change forever.
      actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
    }
  }
}

This is the highest-value paragraph in the post. Without the service endpoints, the app deploys clean and then its Key Vault references resolve to nothing — so a missing credential presents as missing data, not as a network denial. Nothing in CI catches it and terraform validate passes.

6. Guard the stateful resources

lifecycle {
  prevent_destroy = true
}

On the Cosmos account, both storage accounts, and the Key Vault. A plan that wants to replace one of these now fails until a human removes the guard in a reviewed change.

This is cheap to add on day one and impossible to add retroactively at the moment you need it.

7. Plan, review, apply

terraform fmt -recursive -check
terraform validate
terraform plan          # read every line
terraform apply

Review the plan for destroy/create pairs on anything stateful, and for changes you cannot trace to your diff. Anything surprising: discard and fix in a new change rather than applying and correcting.


How to know it worked

terraform plan   # → "No changes. Your infrastructure matches the configuration."

An empty plan immediately after an apply is the real check. A plan that still proposes changes means something in your configuration does not converge, and that condition never improves on its own.

Then confirm the posture rather than assuming it:

# Everything is where you think it is
az resource list --query "[?location!='centralus'].{n:name,l:location}" -o table

# Key Vault denies by default and admits only the subnet
az keyvault show -n kv-app-prod-cus-01 -g rg-sec-app-prod-cus \
  --query "properties.networkAcls" -o json

# The app has an identity
az functionapp identity show -n func-app-prod-cus-01 -g rg-web-app-prod-cus

When it doesn't work

MissingSubscriptionRegistration — a resource provider is not registered on this subscription. Add it to resource_providers_to_register. Appears at apply, never at plan.

AADSTS70021: No matching federated identity record found — the token's subject does not match any federated credential, exactly and case-sensitively. Compare them character by character; the usual causes are a missing run_phase credential, or an organisation, project or workspace name that differs in case or spacing from what you assumed.

LocationNotAvailableForResourceType — ARM does not offer that resource type in that region. The error lists valid regions, and that list may include a region your subscription still cannot use for that service. Run both checks from Prerequisites.

Cosmos ServiceUnavailable, mentioning availability zones — usually not about zones. Check isSubscriptionRegionAccessAllowedForRegular for the region first; the message points at a setting that is frequently not the cause.

Vault name already exists, and is nowhere in your tenant — global namespace. Someone else has it. Use the instance suffix: kv-app-prod-cus-02.

A resource group will not delete, "still contains Resources" — something created a resource Terraform does not manage inside it. Application Insights creates a Smart Detection action group this way. Delete that object rather than setting prevent_deletion_if_contains_resources = false, which disables the check for every resource group you will ever have.


What's next

Part 2 deploys the application onto this: locking the origin so only your CDN can reach it, wiring GitHub Actions to deploy without credentials, and the authentication detail that silently breaks every deploy.