Terraform on Azure

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Azure is the cloud where choosing the provider is part of the design. Three HashiCorp-registry providers cover three different control planes: azurerm for the Azure Resource Manager (ARM) resources that have been modelled by hand, azapi for any ARM resource type and API version that you can describe as a request body, and azuread for Microsoft Entra ID objects that are served by Microsoft Graph instead of ARM. Authentication has more variants than on the other clouds (Azure CLI, a service principal with a secret or a certificate, OpenID Connect, a managed identity, AKS workload identity), and the azurerm backend, which keeps state in Blob Storage, has its own set of them. Microsoft also maintains a library of reusable modules, Azure Verified Modules, and these modules are written with azapi, not with azurerm.

This page targets Terraform 1.17 (released 2026-10-21) and OpenTofu 1.13. The provider versions were read from the Terraform Registry and the providers' repositories on 2026-10-23: hashicorp/azurerm 5.11.0, Azure/azapi 2.13.0, hashicorp/azuread 3.10.0 and hashicorp/random 3.9.1. The current major of AzureRM is therefore 5, released in July 2026; 4.x, which many tutorials still use, is the previous major (see What changed recently). Every argument shown in the examples was checked against the provider documentation in the provider repositories, but the examples were not applied to a real subscription: treat the names, address ranges, SKUs and sizes as placeholders to review. Resource arguments and the list of Resource Providers differ between provider versions, so read the documentation of the version you pin.

The page follows Providers, which explains required_providers and the lock file, and Backends and Remote State, which compares backends. It uses the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers storage account, network, secrets and identity). The Azure services themselves are not explained again here: each section links to the page of the Azure section that does. The same scenario on the other clouds is on Terraform on AWS and Terraform on Google Cloud. For a one-page comparison of Terraform with Bicep, see Azure Infrastructure as Code.

The Azure providers

Provider What it manages Notes

hashicorp/azurerm

ARM resources and data sources that are modelled one by one: resource groups, virtual networks, storage accounts, Container Apps, PostgreSQL flexible servers, Key Vault, role assignments.

A new service or feature appears when the provider adds it. Version 5.0 was a major release (see What changed recently). Also exposes two provider functions, provider::azurerm::normalise_resource_id and provider::azurerm::parse_resource_id.

Azure/azapi

Any ARM resource type at any API version through generic resources (azapi_resource, azapi_update_resource, azapi_resource_action, azapi_data_plane_resource) plus data sources and provider functions.

A thin layer over the ARM REST API: the request body is written in HCL. Same-day access to preview services. The registry source is Azure/azapi.

hashicorp/azuread

Microsoft Entra ID (formerly Azure Active Directory) objects through Microsoft Graph: applications, service principals, groups, users, federated identity credentials.

Authenticated separately from azurerm and needs Microsoft Graph application roles or Entra directory roles, not Azure RBAC.

Two other providers appear in the Microsoft overview and are outside this page: microsoft/azuredevops for Azure DevOps projects and pipelines, and hashicorp/azurestack for Azure Stack Hub. The Azure naming comes with a trap: all Azure providers read the same ARM_* environment variables (ARM_CLIENT_ID, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID, ARM_USE_OIDC, ARM_USE_CLI and so on), so one set of variables authenticates azurerm, azapi and the azurerm backend at once. The azuread provider reads ARM_CLIENT_ID, ARM_TENANT_ID and the OIDC variables too, but it addresses a different API (Microsoft Graph), so the same identity also needs Graph or Entra permissions.

AzureRM or AzAPI?

Microsoft describes the two as complementary. The documented usage order for AzAPI is to start with azapi_resource, use azapi_data_plane_resource for the small set of supported data plane objects, use azapi_update_resource to set a property that exists on an AzureRM-managed resource but not in the AzureRM schema, and use azapi_resource_action for one-off operations that are not CRUD (for example powering off a virtual machine). In practice:

  • Use azurerm by default. Its arguments are validated at plan time against a hand-written schema, many values that ARM treats as free-form strings become typed arguments and blocks, and the documentation lists every argument.

  • Use azapi when azurerm does not have the resource or the property yet (a preview service, a feature that was just released), when you want to pin an API version, or when you are following a Bicep or ARM template and want the same property names.

  • Mix them when it helps: create the resource with azurerm and extend it with azapi_update_resource. Changes made by azapi_update_resource to an AzureRM-managed object can show up as drift in the AzureRM resource, so keep the boundary narrow and documented.

  • The Azure Verified Modules (see Azure Verified Modules) are written with azapi. A module composition therefore often needs both providers configured.

Authenticating to Azure

The AzureRM provider documents five ways of authenticating: the Azure CLI, a managed identity, a service principal with a client certificate, a service principal with a client secret, and a service principal with OpenID Connect; the AKS workload identity has its own guide. The documentation’s recommendation is to authenticate with the Azure CLI on a workstation, and with a service principal (OIDC) or a managed identity when Terraform runs non-interactively, for example in CI. The Azure side of identity (Entra ID, RBAC, managed identities and workload identity federation) is explained in Azure Identity and Access; this page only configures Terraform.

Method Provider arguments and environment When to use it

Azure CLI

use_cli (ARM_USE_CLI), default true; run az login first

A developer workstation. Requires the az CLI on the PATH; service principals and managed identities can also log in to the CLI (az login --service-principal, az login --identity).

OpenID Connect (workload identity federation)

use_oidc (ARM_USE_OIDC), client_id, tenant_id, subscription_id; token inputs oidc_request_token and oidc_request_url, oidc_token, oidc_token_file_path, or ado_pipeline_service_connection_id

CI/CD from GitHub Actions or Azure DevOps. No secret is stored: the pipeline exchanges its own short-lived token for an Azure access token.

Managed identity

use_msi (ARM_USE_MSI), optionally client_id for a user-assigned identity, msi_endpoint

Terraform running on an Azure VM, a container or a self-hosted agent that has an identity attached.

AKS workload identity

use_aks_workload_identity (ARM_USE_AKS_WORKLOAD_IDENTITY)

Terraform running as a pod; client_id, tenant_id and oidc_token_file_path are detected from the environment.

Service principal and client secret

client_id, client_secret or client_secret_file_path, tenant_id

Legacy. A secret that you must rotate and keep out of state, logs and plans.

Service principal and client certificate

client_certificate or client_certificate_path with client_certificate_password

Legacy, for the same reasons.

Since version 4.0 the subscription ID is mandatory for plan and apply, whatever the authentication method. Set subscription_id in the provider block or ARM_SUBSCRIPTION_ID in the environment. The reason given in the 4.0 upgrade guide is that, when the ID was optional, the Azure CLI’s currently selected subscription was used silently and the provider could not guarantee that a plan ran against the intended subscription. The provider documentation adds that the value is not required to run terraform validate.

Locally with the Azure CLI

Log in, pick the subscription, and keep the subscription ID in an environment variable rather than in the code:

az login
az account list --output table
az account set --subscription "<subscription-name-or-id>"
export ARM_SUBSCRIPTION_ID="$(az account show --query id --output tsv)"
terraform init
terraform plan

The CLI session can be that of a user or, from AzureRM 3.44, of a service principal or managed identity. For the Azure sovereign clouds, configure the CLI first with az cloud set --name AzureChinaCloud (or AzureUSGovernment) and set the provider argument environment (ARM_ENVIRONMENT) to match.

In CI with OpenID Connect

A pipeline should not hold a secret for Azure. With OIDC, the app registration (or user-assigned managed identity) has a federated identity credential that trusts the CI system’s token issuer and subject, and the provider performs the token exchange itself. Declaring the credential in Terraform is shown in CI/CD and Automation; the shape is:

terraform {
  required_version = ">= 1.11"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.0"
    }
  }
}

provider "azurerm" {
  features {}

  # Values normally come from ARM_CLIENT_ID, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID and ARM_USE_OIDC,
  # set as variables of the pipeline. None of them is a secret, but none is hard-coded either.
  use_oidc = true
}

For GitHub Actions the provider reads ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN itself, as long as the job has permissions: id-token: write. For Azure DevOps Pipelines it uses SYSTEM_ACCESSTOKEN and SYSTEM_OIDCREQUESTURI and also needs ado_pipeline_service_connection_id (ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID), and the AzureRM guide recommends the AzureCLI@2 task over the optional TerraformTaskV4@4 task. A complete GitHub Actions job is in A GitHub Actions plan job. The subject of the federated credential has to match the token the pipeline presents exactly (branch, environment, pull request or tag), and the format of the GitHub subject claim has changed for new repositories; the CI page explains both points.

With a managed identity

On a VM, a container or a self-hosted runner that has an identity assigned, set ARM_USE_MSI=true, plus ARM_CLIENT_ID when the identity is user-assigned, and the subscription and tenant IDs. The guide recommends against running Terraform inside an Azure Function App, because its low memory ceiling can terminate Terraform in the middle of a run and lose the state.

export ARM_USE_MSI=true
export ARM_SUBSCRIPTION_ID="<subscription-id>"
export ARM_TENANT_ID="<tenant-id>"
export ARM_CLIENT_ID="<client-id-of-the-user-assigned-identity>"   # omit for a system-assigned identity
terraform init && terraform plan

What the identity needs

Azure RBAC has two planes, and Terraform needs both:

  • Control plane (ARM): a role such as Contributor at the scope that Terraform manages. A Terraform configuration that also creates role assignments needs permission to write them, which Contributor does not include; the managed identity guide says to use Owner or Contributor together with User Access Administrator when the configuration assigns roles. Scope the roles to the resource group or subscription that holds the Bookshelf environment; the Azure RBAC model is in Azure Identity and Access.

  • Data plane: the backend needs Storage Blob Data Contributor on the state container (see The azurerm backend), and a configuration that writes Key Vault secrets needs a Key Vault data role such as Key Vault Secrets Officer.

  • Microsoft Graph, for azuread: with a service principal, azuread_application needs the application role Application.ReadWrite.OwnedBy or Application.ReadWrite.All; with OwnedBy, the Terraform principal must be an owner of the application. azuread_group needs Group.Create plus User.Read.All, Application.Read.All or Directory.Read.All when it sets members or owners.

The azurerm provider block

provider "azurerm" {
  subscription_id = var.subscription_id # or ARM_SUBSCRIPTION_ID

  # Which Resource Providers Terraform registers on the subscription (see below)
  resource_provider_registrations = "none"
  resource_providers_to_register = [
    "Microsoft.App",
    "Microsoft.Cache",
    "Microsoft.ContainerRegistry",
    "Microsoft.DBforPostgreSQL",
    "Microsoft.KeyVault",
    "Microsoft.ManagedIdentity",
    "Microsoft.Network",
    "Microsoft.OperationalInsights",
    "Microsoft.Storage",
  ]

  # Use Entra ID rather than the storage account key for blob and queue data plane calls
  storage_use_azuread = true

  features {
    resource_group {
      prevent_deletion_if_contains_resources = true
    }
    key_vault {
      purge_soft_delete_on_destroy    = false
      recover_soft_deleted_key_vaults = true
    }
    log_analytics_workspace {
      permanently_delete_on_destroy = false
    }
  }
}

The features {} block

features is the only required argument of the provider block; an empty features {} selects the defaults. It exists because Azure has destructive behaviours that different teams want different ways, such as whether a virtual machine’s OS disk is deleted with the machine, or whether a Key Vault is purged when destroyed. Each nested block configures one resource family. The blocks that matter for the Bookshelf baseline:

Block and argument Effect Documented default

resource_group / prevent_deletion_if_contains_resources

terraform destroy refuses to delete a resource group that still contains resources that Terraform does not manage.

true

key_vault / purge_soft_delete_on_destroy

Permanently deletes (purges) the vault after destroying it, which frees the globally unique name.

true

key_vault / recover_soft_deleted_key_vaults

When creating a vault whose name belongs to a soft-deleted one, recover that vault rather than failing.

true

log_analytics_workspace / permanently_delete_on_destroy

Permanently deletes the workspace rather than leaving it soft-deleted.

false

enhanced_validation / locations, resource_providers, preflight_enabled

Plan-time validation of regions and Resource Providers, and the opt-in preflight check described below.

Locations and Resource Providers: disabled since 5.0. Preflight: opt-in.

A soft-deleted Key Vault keeps its name reserved until the retention period ends (7 to 90 days, soft_delete_retention_days), and a vault with purge protection cannot be purged at all until that period ends. That is why a teardown-and-recreate cycle of a vault with a fixed name can fail, and why the clean-up section of this page suffixes the names.

resource_provider_registrations and resource_providers_to_register

An Azure subscription has to register a Resource Provider (for example Microsoft.App for Container Apps) before it can create resources of that provider’s types. Before each plan or apply the provider can register the providers that your configuration needs. Two arguments control this:

  • resource_provider_registrations selects a predefined set: core (a small set for essential services including Compute, Networking and Storage), extended (a larger set for the most common resources), all (every provider that any resource in the provider might need), legacy (the set that earlier provider versions registered) or none (register nothing). It can also come from ARM_RESOURCE_PROVIDER_REGISTRATIONS.

  • resource_providers_to_register is a list of exact Resource Provider names to register in addition to, or instead of, the set. The API Providers listed at the bottom of each resource’s documentation page are the names that resource needs.

The default changed in AzureRM 5.0: it is none, where in 4.x it was legacy, a set of about sixty providers. A configuration that relied on automatic registration therefore starts failing with errors about unregistered providers, unless it sets resource_provider_registrations to legacy or, better, lists the providers it uses. The documentation states that the predefined sets are no longer updated and recommends registering only what the configuration needs; none is the recommended value when subscription-level registration is managed outside Terraform or the identity has no permission to register providers. The old skip_provider_registration argument was removed in 5.0 (in 4.0 it was replaced by none).

# What is registered on the subscription right now
az provider list --query "[?registrationState=='Registered'].namespace" --output tsv

# Register one provider by hand, outside Terraform
az provider register --namespace Microsoft.App

Other provider arguments worth knowing

  • storage_use_azuread (ARM_STORAGE_USE_AZUREAD), default false: use Entra ID instead of the account key for the Blob and Queue data plane. It is required in practice when a storage account has shared_access_key_enabled = false, and it needs the Storage data roles on the identity, which Azure adds to new Contributor and Owner assignments but did not backport to existing ones. The Files API does not support it.

  • partner_id and disable_terraform_partner_id: usage attribution. By default the provider sends a Terraform partner ID to Microsoft; set disable_terraform_partner_id (ARM_DISABLE_TERRAFORM_PARTNER_ID) to true to stop it. The partner ID gives HashiCorp no access to usage information, according to the documentation.

  • environment and metadata_host: sovereign and custom clouds.

  • auxiliary_tenant_ids: up to three other tenants for cross-tenant scenarios.

  • Several azurerm provider blocks with alias work as in any provider, for example to manage resources across subscriptions; see Providers.

Plan-time validation and preflight

Two features move errors from apply to plan. Enhanced validation caches the list of regions and Resource Providers from the Azure metadata service and validates location and providers at plan time; since 5.0 it is off by default (the documentation gives two reasons: a region can work even when it is missing from the ARM region list, and the metadata call adds latency), and the blocks moved inside features. Preflight validation is a 5.x feature: with preflight_enabled = true inside enhanced_validation, the provider submits the payload of supported resources to the Azure deployment validation API during terraform plan, which can surface policy violations, quota breaches and invalid property values before apply. It needs valid credentials at plan time, covers only a subset of resource types, and silently skips the rest and any argument that is not known until apply. The page for the feature lists azurerm_managed_redis, among others, as supported. The AzAPI provider has its own enable_preflight argument.

provider "azurerm" {
  features {
    enhanced_validation {
      locations                   = true
      resource_providers          = true
      preflight_enabled           = true
      preflight_location_fallback = "eastus2"
    }
  }
}

Provider functions

Since Terraform 1.8, a provider can ship functions. azurerm has two: provider::azurerm::normalise_resource_id(id) fixes the casing of the system segments of a resource ID (ARM is inconsistent about casing, which makes comparing IDs fragile), and provider::azurerm::parse_resource_id(id) returns a map with keys such as subscription_id, resource_group_name, resource_provider, resource_type and resource_name.

locals {
  parsed = provider::azurerm::parse_resource_id(azurerm_container_registry.this.id)
}

output "registry_resource_group" {
  value = local.parsed["resource_group_name"]
}

The azapi provider

AzAPI is the way to reach an Azure feature that azurerm does not have yet. The provider has no per-service schema: you give an ARM resource type that includes the API version (Microsoft.Storage/storageAccounts@2025-06-01), the parent_id (the ID of the resource group or parent resource), the name, and a body that mirrors the REST payload. Its documentation compares the two providers with a storage account:

terraform {
  required_version = ">= 1.11"

  required_providers {
    azapi = {
      source  = "Azure/azapi"
      version = "~> 2.13"
    }
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.0"
    }
  }
}

provider "azapi" {}

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "bookshelf" {
  name     = "rg-bookshelf"
  location = "eastus2"
}

# AzAPI: the API version is part of "type"; "body" is the REST payload written in HCL
resource "azapi_resource" "covers_archive" {
  type      = "Microsoft.Storage/storageAccounts@2025-06-01"
  parent_id = azurerm_resource_group.bookshelf.id
  name      = "stbookshelfarchive01"
  location  = azurerm_resource_group.bookshelf.location

  body = {
    kind = "StorageV2"
    sku = {
      name = "Standard_LRS"
    }
    properties = {
      allowBlobPublicAccess = false
      minimumTlsVersion     = "TLS1_2"
    }
  }

  response_export_values = ["properties.primaryEndpoints.blob"]
}

output "archive_blob_endpoint" {
  value = azapi_resource.covers_archive.output.properties.primaryEndpoints.blob
}

Source: Terraform Registry — AzAPI provider and Terraform Registry — azapi_resource. The property names allowBlobPublicAccess and minimumTlsVersion are those of the Microsoft.Storage REST API; check them in the API reference of the version you pin.

The arguments of azapi_resource that the examples depend on:

Argument Meaning

type, parent_id, name, location

type is resource-type@api-version. parent_id is a resource group ID (recommended to be managed with azurerm_resource_group), a management group, a subscription (/subscriptions/<id>), the tenant (/), the resource that an extension resource extends, or, for a child resource, its parent. Changing name or parent_id replaces the resource.

body

A dynamic HCL object that is the request body. Since AzAPI 2.0 it is not a JSON string, so no jsonencode is needed and Terraform’s own lifecycle rules work on it (ignore_changes = [body.properties.x]).

response_export_values

Which parts of the response become the read-only output attribute: a list of paths (["*"] exports everything) or a map of names to JMESPath queries.

identity, tags

Managed identity and tags, with the same shape as in azurerm.

sensitive_body and sensitive_body_version

A write-only part of the body (not stored in state, Terraform 1.11 or later), resent only when its version changes.

schema_validation_enabled

Validates type and body against an embedded schema at plan time. Defaults to true.

ignore_casing, ignore_missing_property, ignore_null_property, ignore_body_changes

Tuning of the diff between the configuration and what the API returns, such as properties that the API never returns.

replace_triggers_external_values, replace_triggers_refs

Force replacement when a value or a property of the configuration changes.

retry, timeouts, locks

Retry on matching error messages, operation timeouts, and ARM resource IDs to serialise operations on.

create_headers, create_query_parameters (and read_, update_, delete_)

Extra headers and query parameters for the request.

The other resources work as follows. azapi_update_resource takes type, resource_id and body and sets properties on a resource that another resource owns. azapi_resource_action runs one ARM action (type, resource_id, action) without managing a lifecycle. azapi_data_plane_resource manages a documented subset of data plane objects. Data sources include azapi_resource, azapi_client_config, azapi_resource_list and azapi_resource_id. AzAPI 2.0 and later also include provider functions such as build_resource_id and parse_resource_id.

# Set a property that the azurerm schema does not expose, on a resource that azurerm owns
resource "azapi_update_resource" "registry_policy" {
  type        = "Microsoft.ContainerRegistry/registries@2025-04-01"
  resource_id = azurerm_container_registry.this.id

  body = {
    properties = {
      anonymousPullEnabled = false
    }
  }
}

Source: Microsoft Learn — Overview of the Terraform AzAPI provider, usage hierarchy and examples. The API version and the property above are an illustration derived from the documentation’s example; confirm them in the Microsoft.ContainerRegistry REST API reference before use.

Provider-level behaviour worth knowing: default_location, default_name and default_tags apply to every resource that does not set its own; enable_preflight turns on plan-time validation; ignore_no_op_changes (default true) suppresses differences between the configuration and a normalised API response; skip_provider_registration is still an argument of this provider (default false); disable_default_output controls the output attribute when response_export_values is omitted. Authentication is identical to azurerm, including OIDC (use_oidc) and oidc_azure_service_connection_id for Azure DevOps.

Tools that help with AzAPI and with existing infrastructure

  • The Microsoft Terraform extension for Visual Studio Code completes resource types, API versions and properties for both providers; its features include converting ARM JSON to azapi_resource, exporting resources and migrating between providers.

  • Azure Export for Terraform (aztfexport) generates HCL and state from existing Azure resources. It has three commands: aztfexport resource <resource-id>, aztfexport resource-group <resource-group-name> and aztfexport query <Azure Resource Graph predicate>. It defaults to azurerm; --provider-name=azapi exports AzAPI resources instead. Telemetry is on by default and is turned off with aztfexport config set telemetry_enabled false. The output is a starting point: review it, split it into modules, and compare it with Import and Query, which describes the import block that Terraform itself provides.

  • aztfmigrate migrates resources between AzAPI and AzureRM, in the HCL and in the state, with a plan mode and a migrate mode.

The azuread provider

azuread manages Microsoft Entra ID through Microsoft Graph. It has its own provider block (tenant_id and the same authentication methods as azurerm: CLI, managed identity, certificate, secret and OIDC) and its own permission model, so a pipeline identity that can create Azure resources cannot necessarily create an app registration. Typical uses in a Bookshelf deployment are: the app registration and service principal that a pipeline uses, the federated identity credential that trusts the repository, and security groups that RBAC roles are assigned to.

terraform {
  required_version = ">= 1.11"

  required_providers {
    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 3.10"
    }
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.0"
    }
  }
}

provider "azuread" {}

provider "azurerm" {
  features {}
}

data "azuread_client_config" "current" {}

resource "azuread_group" "bookshelf_operators" {
  display_name     = "bookshelf-operators"
  owners           = [data.azuread_client_config.current.object_id]
  security_enabled = true
}

resource "azuread_application" "terraform" {
  display_name = "bookshelf-terraform"
  owners       = [data.azuread_client_config.current.object_id]
}

resource "azuread_service_principal" "terraform" {
  client_id = azuread_application.terraform.client_id
  owners    = [data.azuread_client_config.current.object_id]
}

resource "azuread_application_federated_identity_credential" "main" {
  application_id = azuread_application.terraform.id
  display_name   = "github-main"
  audiences      = ["api://AzureADTokenExchange"]
  issuer         = "https://token.actions.githubusercontent.com"
  subject        = "repo:${var.github_repository}:ref:refs/heads/main"
}

variable "github_repository" {
  type        = string
  description = "GitHub repository in the form owner/name"
}

variable "state_container_id" {
  type        = string
  description = "Resource ID of the blob container that holds the Terraform state (see the backend section)."
}

# Give the service principal a data plane role on the container that holds state
resource "azurerm_role_assignment" "state" {
  scope                = var.state_container_id
  role_definition_name = "Storage Blob Data Contributor"
  principal_id         = azuread_service_principal.terraform.object_id
}

azuread_application exports client_id (the application’s client ID) and id (the Terraform resource ID that the federated credential’s application_id expects); azuread_service_principal exports object_id, which is the principal_id that role assignments need. The registry also offers the newer azuread_application_registration resource, which is used in the provider’s own example.

The azurerm backend

The azurerm backend stores the state as a blob in a container of a storage account, and uses native Azure Blob Storage capabilities for locking and consistency checking. It is the Azure counterpart of s3 and gcs in Backends and Remote State; state files and what they contain are in State. Azure’s storage accounts are described in Azure Storage Accounts and Blob Storage in Azure Blob Storage.

The backend needs to authenticate to the storage account data plane and to know the data plane URL. The documentation lists the authentication methods in order of preference:

Method How

Microsoft Entra ID (recommended)

use_azuread_auth = true (ARM_USE_AZUREAD) plus one of: OIDC (use_oidc, client_id), a compute-attached managed identity (use_msi, and client_id for a user-assigned one), the Azure CLI (use_cli), a client secret or a client certificate. tenant_id is required; with the CLI it can be inferred.

SAS token (not recommended for new workloads)

sas_token (ARM_SAS_TOKEN)

Access key (not recommended)

access_key (ARM_ACCESS_KEY)

Access key lookup (not recommended)

Authenticates to ARM with an Entra principal and looks up the account key; needs subscription_id and resource_group_name.

The data plane role for the Entra ID method is Storage Blob Data Contributor, recommended at the scope of the container. Reader on the storage account is only needed when lookup_blob_endpoint = true, which is required if the account uses the Azure DNS zone endpoints feature. The configuration arguments are storage_account_name, container_name and key (required), then environment, metadata_host, snapshot, tenant_id, subscription_id, resource_group_name and the credential arguments above. The documentation recommends supplying credentials through environment variables: values passed with -backend-config or hard-coded are written to the .terraform directory and to plan files.

terraform {
  required_version = ">= 1.11"

  backend "azurerm" {
    use_oidc         = true # ARM_USE_OIDC
    use_azuread_auth = true # ARM_USE_AZUREAD

    # tenant_id (ARM_TENANT_ID) and client_id (ARM_CLIENT_ID) come from the environment
    storage_account_name = "stbookshelftfstate01"
    container_name       = "tfstate"
    key                  = "bookshelf/prod.terraform.tfstate"
  }
}

The snapshot argument (ARM_SNAPSHOT) takes a snapshot of the state blob before it is used, which gives a rollback point; blob versioning on the container (below) does the same without a Terraform setting. On a developer workstation the same backend works with use_cli = true and use_azuread_auth = true after az login. The terraform_remote_state data source needs the same configuration as the backend block, in its config map, and cannot read environment variables for storage_account_name, container_name and key; the alternatives that do not couple one configuration to another’s state are listed in the Backends page.

Creating the storage account

The backend does not create its own storage; something has to exist first. Microsoft’s walkthrough creates it with the CLI, and the commands below follow it, with the hardening arguments that remove the shared key, public blob access and old TLS versions:

RESOURCE_GROUP_NAME=rg-bookshelf-tfstate
STORAGE_ACCOUNT_NAME=stbookshelftfstate01   # globally unique, 3-24 lowercase letters and digits
CONTAINER_NAME=tfstate

az group create --name "$RESOURCE_GROUP_NAME" --location eastus2

az storage account create \
  --resource-group "$RESOURCE_GROUP_NAME" \
  --name "$STORAGE_ACCOUNT_NAME" \
  --sku Standard_LRS \
  --kind StorageV2 \
  --allow-blob-public-access false \
  --allow-shared-key-access false \
  --min-tls-version TLS1_2

# Versioning and soft delete make an overwritten or deleted state recoverable
az storage account blob-service-properties update \
  --resource-group "$RESOURCE_GROUP_NAME" \
  --account-name "$STORAGE_ACCOUNT_NAME" \
  --enable-versioning true \
  --enable-delete-retention true --delete-retention-days 14 \
  --enable-container-delete-retention true --container-delete-retention-days 14

# Data plane role for the identity that runs Terraform, at the scope of the account (use the container scope in production)
az role assignment create \
  --assignee-object-id "<object-id-of-the-terraform-identity>" \
  --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Contributor" \
  --scope "$(az storage account show --name "$STORAGE_ACCOUNT_NAME" --query id --output tsv)"

az storage container create --name "$CONTAINER_NAME" --account-name "$STORAGE_ACCOUNT_NAME" --auth-mode login

Source: Microsoft Learn — Store Terraform state in Azure Storage and the Azure CLI reference pages for az storage account, az storage account blob-service-properties and az role assignment. The Microsoft page authenticates with an access key; the Entra ID variant above is the one the backend documentation recommends. A role assignment can take a few minutes to propagate, so the container creation may need a retry.

Microsoft’s page also offers a Terraform version of the bootstrap. If you prefer it, keep it as a separate configuration with local state, apply it once, and then either leave its state local (it changes almost never) or migrate it to the new container with terraform init -migrate-state. The page’s warnings apply: changing a storage account’s redundancy outside Terraform can make the next plan replace the account, so put prevent_destroy = true in its lifecycle block, and reconcile with terraform apply -refresh-only after a conversion. Never put the state storage in the same configuration whose state it holds if you intend to destroy that configuration.

Securing the state account

The state contains secrets in clear text (see State and Sensitive Data, Ephemeral Values and Write-only Arguments). For Azure:

  • Disable the shared key and use Entra ID (use_azuread_auth), so a leaked account key does not expose state.

  • Grant Storage Blob Data Contributor to the pipeline identity at the scope of the container, and Reader only where a person needs to inspect it. One container per environment keeps the blast radius small; see Workspaces, Environments and Project Structure.

  • Enable blob versioning and soft delete; Azure Storage encrypts data at rest by default, and customer-managed keys are an account setting.

  • Restrict the network: a storage firewall, a service endpoint or a private endpoint (the Microsoft page notes that public network access is left on in its example and should be restricted in production). With a private endpoint, the runner must be able to resolve and reach it, which rules out hosted runners; see Azure Virtual Networks.

  • Lock the account with a resource lock (CanNotDelete) managed outside the configuration it protects, and log data plane access with diagnostic settings (Azure Monitoring and Observability).

  • OpenTofu adds client-side state and plan encryption with an azure_vault key provider; see Backends and Remote State.

A Bookshelf baseline on Azure

This baseline provisions the infrastructure that the Azure section builds by hand: the resource group, a virtual network with two delegated subnets, Log Analytics, a container registry, a Container Apps environment that runs catalog-api, reviews-api and web, a PostgreSQL flexible server, Azure Managed Redis, a covers storage account and a Key Vault, all joined by one user-assigned managed identity. The resource names follow the Azure section (rg-bookshelf, vnet-bookshelf, acrbookshelf, cae-bookshelf, pg-bookshelf, redis-bookshelf, stbookshelf, kv-bookshelf), with a short suffix for the globally unique ones. The services are explained in Azure Container Apps, Azure Container Registry, PostgreSQL and MySQL on Azure, Redis and Partner Databases on Azure, Azure Key Vault and Secrets, Azure Storage Accounts and Azure Virtual Networks, and the resource organisation in Azure Resource Organization. The Bicep version of a smaller baseline is in Azure Infrastructure as Code.

flowchart LR subgraph rg["rg-bookshelf"] id["Managed identity id-bookshelf-apps"] subgraph net["vnet-bookshelf"] apps["snet-apps delegated to Microsoft.App"] pgnet["snet-postgres delegated to PostgreSQL"] end law["Log Analytics log-bookshelf"] acr["Container Registry acrbookshelf"] cae["Container Apps environment cae-bookshelf"] web["web"] cat["catalog-api"] rev["reviews-api"] pg[("PostgreSQL pg-bookshelf")] redis[("Managed Redis redis-bookshelf")] st["Storage account stbookshelf: covers"] kv["Key Vault kv-bookshelf"] end apps --> cae cae --> law cae --> web cae --> cat cae --> rev pgnet --> pg cat --> pg rev --> pg rev --> redis cat --> st web -. pulls image .-> acr id -. AcrPull .-> acr id -. Secrets User .-> kv id -. Redis data access .-> redis id -. Blob Data Contributor .-> st cat -. secret reference .-> kv

The configuration is split in the usual files. The first file pins versions and configures the providers:

# versions.tf
terraform {
  required_version = ">= 1.11"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.7"
    }
  }

  backend "azurerm" {} # filled with -backend-config=backend.hcl, see the backend section
}

provider "azurerm" {
  subscription_id                 = var.subscription_id
  resource_provider_registrations = "none"
  resource_providers_to_register = [
    "Microsoft.App",
    "Microsoft.Cache",
    "Microsoft.ContainerRegistry",
    "Microsoft.DBforPostgreSQL",
    "Microsoft.KeyVault",
    "Microsoft.ManagedIdentity",
    "Microsoft.Network",
    "Microsoft.OperationalInsights",
    "Microsoft.Storage",
  ]
  storage_use_azuread = true

  features {
    key_vault {
      purge_soft_delete_on_destroy = false
    }
  }
}

# variables.tf
variable "subscription_id" {
  type        = string
  description = "Target subscription. Set with TF_VAR_subscription_id or ARM_SUBSCRIPTION_ID."
}

variable "location" {
  type    = string
  default = "eastus2"
}

variable "suffix" {
  type        = string
  description = "Short lowercase suffix that makes globally unique names unique (3-6 characters)."
  default     = "a1b2c3"
}

variable "catalog_api_image" {
  type        = string
  description = "Image of catalog-api. The default is a public placeholder so that the first apply works before any image is pushed."
  default     = "mcr.microsoft.com/k8se/quickstart:latest"
}

variable "reviews_api_image" {
  type    = string
  default = "mcr.microsoft.com/k8se/quickstart:latest"
}

variable "web_image" {
  type    = string
  default = "mcr.microsoft.com/k8se/quickstart:latest"
}

Source: Terraform Registry — AzureRM provider and Terraform Docs — Backend type: azurerm. The placeholder image comes from the azurerm_container_app documentation example.

backend "azurerm" {} is a partial configuration; the values are supplied with a file passed to terraform init:

# backend.hcl (not secret: IDs of resources, no keys)
storage_account_name = "stbookshelftfstate01"
container_name       = "tfstate"
key                  = "bookshelf/dev.terraform.tfstate"
use_azuread_auth     = true
export ARM_USE_OIDC=true            # in CI; locally use az login and ARM_USE_CLI=true
export TF_VAR_subscription_id="<subscription-id>"
terraform init -backend-config=backend.hcl

The network, identity and monitoring:

# main.tf (1/4): foundation
data "azurerm_client_config" "current" {}

resource "azurerm_resource_group" "bookshelf" {
  name     = "rg-bookshelf"
  location = var.location
  tags     = local.tags
}

locals {
  tags = {
    application = "bookshelf"
    managed_by  = "terraform"
  }
}

resource "azurerm_virtual_network" "bookshelf" {
  name                = "vnet-bookshelf"
  location            = azurerm_resource_group.bookshelf.location
  resource_group_name = azurerm_resource_group.bookshelf.name
  address_space       = ["10.20.0.0/16"]
  tags                = local.tags
}

# Container Apps workload profiles environment: /27 minimum, delegated to Microsoft.App/environments
resource "azurerm_subnet" "apps" {
  name                 = "snet-apps"
  resource_group_name  = azurerm_resource_group.bookshelf.name
  virtual_network_name = azurerm_virtual_network.bookshelf.name
  address_prefixes     = ["10.20.0.0/23"]

  delegation {
    name = "container-apps"

    service_delegation {
      name = "Microsoft.App/environments"
    }
  }
}

# PostgreSQL flexible server with private access: a delegated subnet and a private DNS zone
resource "azurerm_subnet" "postgres" {
  name                 = "snet-postgres"
  resource_group_name  = azurerm_resource_group.bookshelf.name
  virtual_network_name = azurerm_virtual_network.bookshelf.name
  address_prefixes     = ["10.20.2.0/24"]

  delegation {
    name = "postgres"

    service_delegation {
      name    = "Microsoft.DBforPostgreSQL/flexibleServers"
      actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
    }
  }
}

resource "azurerm_private_dns_zone" "postgres" {
  name                = "pg-bookshelf.private.postgres.database.azure.com"
  resource_group_name = azurerm_resource_group.bookshelf.name
  tags                = local.tags
}

resource "azurerm_private_dns_zone_virtual_network_link" "postgres" {
  name                  = "vnet-bookshelf"
  private_dns_zone_id   = azurerm_private_dns_zone.postgres.id
  virtual_network_id    = azurerm_virtual_network.bookshelf.id
  resource_group_name   = azurerm_resource_group.bookshelf.name
  tags                  = local.tags
}

resource "azurerm_user_assigned_identity" "apps" {
  name                = "id-bookshelf-apps"
  location            = azurerm_resource_group.bookshelf.location
  resource_group_name = azurerm_resource_group.bookshelf.name
  tags                = local.tags
}

resource "azurerm_log_analytics_workspace" "bookshelf" {
  name                = "log-bookshelf"
  location            = azurerm_resource_group.bookshelf.location
  resource_group_name = azurerm_resource_group.bookshelf.name
  sku                 = "PerGB2018"
  retention_in_days   = 30
  tags                = local.tags
}

resource "azurerm_container_registry" "bookshelf" {
  name                = "acrbookshelf${var.suffix}"
  location            = azurerm_resource_group.bookshelf.location
  resource_group_name = azurerm_resource_group.bookshelf.name
  sku                 = "Basic"
  admin_enabled       = false
  tags                = local.tags
}

The subnet for a workload profiles environment needs a /27 at minimum and must be delegated to Microsoft.App/environments; a consumption-only environment would need a /23 and no delegation, which is why the environment below declares a Consumption workload profile. A /23 is larger than necessary but leaves room for scale-out, because the subnet cannot be resized after the environment is created. Azure may add default delegation actions, so the actions list of that delegation is left out. The Azure naming of the PostgreSQL private DNS zone has to end in .postgres.database.azure.com.

Secrets come next. The database password is generated by an ephemeral resource and consumed by write-only arguments, so it is never written to state or to the plan, as explained in Sensitive Data, Ephemeral Values and Write-only Arguments. Terraform 1.11 or later is required (required_version above).

# main.tf (2/4): data and secrets
ephemeral "random_password" "db" {
  length  = 32
  special = false
}

resource "azurerm_postgresql_flexible_server" "bookshelf" {
  name                = "pg-bookshelf-${var.suffix}"
  location            = azurerm_resource_group.bookshelf.location
  resource_group_name = azurerm_resource_group.bookshelf.name

  version                       = "17"
  sku_name                      = "B_Standard_B1ms"
  storage_mb                    = 32768
  zone                          = "1"
  delegated_subnet_id           = azurerm_subnet.postgres.id
  private_dns_zone_id           = azurerm_private_dns_zone.postgres.id
  public_network_access_enabled = false

  administrator_login               = "bookshelf_admin"
  administrator_password_wo         = ephemeral.random_password.db.result
  administrator_password_wo_version = 1 # increment to rotate

  tags = local.tags

  depends_on = [azurerm_private_dns_zone_virtual_network_link.postgres]

  lifecycle {
    ignore_changes = [zone]
  }
}

resource "azurerm_postgresql_flexible_server_database" "catalog" {
  name      = "catalog"
  server_id = azurerm_postgresql_flexible_server.bookshelf.id
  charset   = "UTF8"
  collation = "en_US.utf8"
}

resource "azurerm_postgresql_flexible_server_database" "reviews" {
  name      = "reviews"
  server_id = azurerm_postgresql_flexible_server.bookshelf.id
  charset   = "UTF8"
  collation = "en_US.utf8"
}

resource "azurerm_managed_redis" "bookshelf" {
  name                = "redis-bookshelf-${var.suffix}"
  location            = azurerm_resource_group.bookshelf.location
  resource_group_name = azurerm_resource_group.bookshelf.name
  sku_name            = "Balanced_B0"

  high_availability_enabled = false # a development setting; the default is true and cannot be changed in place

  default_database {
    access_keys_authentication_enabled = false # Microsoft Entra ID authentication only
  }

  tags = local.tags
}

resource "azurerm_managed_redis_access_policy_assignment" "apps" {
  managed_redis_id = azurerm_managed_redis.bookshelf.id
  object_id        = azurerm_user_assigned_identity.apps.principal_id
}

resource "azurerm_key_vault" "bookshelf" {
  name                       = "kv-bookshelf-${var.suffix}"
  location                   = azurerm_resource_group.bookshelf.location
  resource_group_name        = azurerm_resource_group.bookshelf.name
  tenant_id                  = data.azurerm_client_config.current.tenant_id
  sku_name                   = "standard"
  rbac_authorization_enabled = true
  soft_delete_retention_days = 7
  purge_protection_enabled   = false # a development setting
  tags                       = local.tags
}

# Whoever runs Terraform must be able to write secrets to the vault
resource "azurerm_role_assignment" "deployer_secrets" {
  scope                = azurerm_key_vault.bookshelf.id
  role_definition_name = "Key Vault Secrets Officer"
  principal_id         = data.azurerm_client_config.current.object_id
}

resource "azurerm_key_vault_secret" "db_password" {
  name             = "db-password"
  key_vault_id     = azurerm_key_vault.bookshelf.id
  value_wo         = ephemeral.random_password.db.result
  value_wo_version = 1 # keep in step with administrator_password_wo_version

  depends_on = [azurerm_role_assignment.deployer_secrets]
}

resource "azurerm_storage_account" "covers" {
  name                = "stbookshelf${var.suffix}"
  location            = azurerm_resource_group.bookshelf.location
  resource_group_name = azurerm_resource_group.bookshelf.name

  account_tier                    = "Standard"
  account_replication_type        = "LRS"
  min_tls_version                 = "TLS1_2"
  allow_nested_items_to_be_public = false
  shared_access_key_enabled       = false
  default_to_oauth_authentication = true

  blob_properties {
    versioning_enabled = true

    delete_retention_policy {
      days = 7
    }
  }

  tags = local.tags
}

resource "azurerm_storage_container" "covers" {
  name                  = "covers"
  storage_account_id    = azurerm_storage_account.covers.id
  container_access_type = "private"
}

Source: azurerm_postgresql_flexible_server, azurerm_managed_redis, azurerm_managed_redis_access_policy_assignment, azurerm_key_vault, azurerm_key_vault_secret, azurerm_storage_account, azurerm_storage_container and random — ephemeral random_password. The PostgreSQL version, SKU and zone, and the Managed Redis SKU, are placeholders: check the versions and SKUs available in your region.

Points to notice in this block:

  • administrator_password_wo and value_wo accept the ephemeral value; the _wo_version companions are what you increment to make Terraform send a new value, because Terraform cannot compare a write-only value with anything. Keep the two versions in step, and never use the same password for several environments.

  • rbac_authorization_enabled is required on azurerm_key_vault since 5.0. The vault uses Azure RBAC rather than access policies; the Terraform identity is given Key Vault Secrets Officer explicitly, because creating the vault does not grant its creator any data plane access when RBAC authorization is on. Role assignments can take minutes to propagate, so the first apply may need a rerun.

  • azurerm_storage_container takes storage_account_id since 5.0; the old storage_account_name argument was removed.

  • With shared_access_key_enabled = false the provider block needs storage_use_azuread = true, as shown above.

  • The PostgreSQL zone is reassigned by Azure on a failover, so it is excluded with ignore_changes, as the resource’s documentation suggests.

  • Managed Redis authenticates the Container Apps' identity with an access policy assignment on the default database; the Managed Redis endpoint listens on port 10000, which the Container Apps below receive as an environment variable from the port attribute. Azure Cache for Redis, the older service, has a retirement date announced; see Redis and Partner Databases on Azure. The Redis endpoint here is still reachable over the public network (the argument public_network_access defaults to Enabled); a private endpoint is the next hardening step.

The Container Apps environment, the apps and the role assignments close the baseline:

# main.tf (3/4): compute
resource "azurerm_container_app_environment" "bookshelf" {
  name                       = "cae-bookshelf"
  location                   = azurerm_resource_group.bookshelf.location
  resource_group_name        = azurerm_resource_group.bookshelf.name
  logs_destination           = "log-analytics"
  log_analytics_workspace_id = azurerm_log_analytics_workspace.bookshelf.id
  infrastructure_subnet_id   = azurerm_subnet.apps.id
  tags                       = local.tags

  workload_profile {
    name                  = "Consumption"
    workload_profile_type = "Consumption"
  }
}

locals {
  apps = {
    "catalog-api" = {
      image    = var.catalog_api_image
      port     = 80
      external = false
      database = "catalog"
    }
    "reviews-api" = {
      image    = var.reviews_api_image
      port     = 80
      external = false
      database = "reviews"
    }
    "web" = {
      image    = var.web_image
      port     = 80
      external = true
      database = null
    }
  }
}

resource "azurerm_container_app" "bookshelf" {
  for_each = local.apps

  name                         = each.key
  container_app_environment_id = azurerm_container_app_environment.bookshelf.id
  resource_group_name          = azurerm_resource_group.bookshelf.name
  revision_mode                = "Single"
  workload_profile_name        = "Consumption"
  tags                         = local.tags

  identity {
    type         = "UserAssigned"
    identity_ids = [azurerm_user_assigned_identity.apps.id]
  }

  registry {
    server   = azurerm_container_registry.bookshelf.login_server
    identity = azurerm_user_assigned_identity.apps.id
  }

  secret {
    name                = "db-password"
    identity            = azurerm_user_assigned_identity.apps.id
    key_vault_secret_id = azurerm_key_vault_secret.db_password.versionless_id
  }

  ingress {
    external_enabled = each.value.external
    target_port      = each.value.port

    traffic_weight {
      latest_revision = true
      percentage      = 100
    }
  }

  template {
    min_replicas = 1
    max_replicas = 3

    container {
      name   = each.key
      image  = each.value.image
      cpu    = 0.25
      memory = "0.5Gi"

      env {
        name  = "DATABASE_HOST"
        value = azurerm_postgresql_flexible_server.bookshelf.fqdn
      }
      env {
        name  = "DATABASE_NAME"
        value = coalesce(each.value.database, "none")
      }
      env {
        name  = "DATABASE_USER"
        value = azurerm_postgresql_flexible_server.bookshelf.administrator_login
      }
      env {
        name        = "DATABASE_PASSWORD"
        secret_name = "db-password"
      }
      env {
        name  = "REDIS_HOST"
        value = azurerm_managed_redis.bookshelf.hostname
      }
      env {
        name  = "REDIS_PORT"
        value = tostring(azurerm_managed_redis.bookshelf.default_database[0].port)
      }
      env {
        name  = "COVERS_BLOB_ENDPOINT"
        value = azurerm_storage_account.covers.primary_blob_endpoint
      }
    }
  }

  lifecycle {
    ignore_changes = [secret] # the Key Vault reference is resolved by the platform
  }

  depends_on = [
    azurerm_role_assignment.apps_acr,
    azurerm_role_assignment.apps_secrets,
  ]
}

# main.tf (4/4): identities and roles
resource "azurerm_role_assignment" "apps_acr" {
  scope                = azurerm_container_registry.bookshelf.id
  role_definition_name = "AcrPull"
  principal_id         = azurerm_user_assigned_identity.apps.principal_id
}

resource "azurerm_role_assignment" "apps_secrets" {
  scope                = azurerm_key_vault.bookshelf.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_user_assigned_identity.apps.principal_id
}

resource "azurerm_role_assignment" "apps_covers" {
  scope                = azurerm_storage_account.covers.id
  role_definition_name = "Storage Blob Data Contributor"
  principal_id         = azurerm_user_assigned_identity.apps.principal_id
}

# outputs.tf
output "web_url" {
  value = "https://${azurerm_container_app.bookshelf["web"].ingress[0].fqdn}"
}

output "registry_login_server" {
  value = azurerm_container_registry.bookshelf.login_server
}

output "covers_blob_endpoint" {
  value = azurerm_storage_account.covers.primary_blob_endpoint
}

Source: azurerm_container_app_environment, azurerm_container_app, azurerm_role_assignment and azurerm_user_assigned_identity. The ignore_changes = [secret] rule follows the resource note that ignore_changes should be used for the secret value when key_vault_secret_id is set; narrow it if you also manage plain secrets.

Notes on the apps:

  • The registry pull and the Key Vault reference both use the same user-assigned identity, which is why the two role assignments must exist before the app is created (depends_on); the identity’s access is then audited in one place.

  • The default images are a public placeholder. After the first apply, build and push the real images (see Azure Container Registry and the Docker pages of this site) and run terraform apply -var catalog_api_image=..., or better a .tfvars file per environment.

  • Container Apps scale rules, jobs and Dapr are separate arguments (http_scale_rule, azure_queue_scale_rule, dapr); the services are in Container Apps: Scaling, Jobs and Dapr. Whether a service belongs on Container Apps, App Service or AKS is a separate decision, covered in Choosing a Compute Service.

  • Provider values that Terraform cannot know before apply (the fqdn of the server, the Redis hostname) are fine in env blocks: the dependency graph orders the creation.

Running it

$ terraform init -backend-config=backend.hcl
$ terraform plan -out=bookshelf.tfplan
...
Plan: 31 to add, 0 to change, 0 to destroy.   # illustrative: the count depends on the configuration
$ terraform apply bookshelf.tfplan
...
Outputs:
web_url = "https://web.<generated-domain>.<region>.azurecontainerapps.io"   # illustrative

Source: Terraform Docs — terraform plan. The output above is abbreviated and illustrative, not captured from a real run.

Costs are real: a Container Apps environment in your own network, a PostgreSQL flexible server, a Managed Redis instance and a Log Analytics workspace all bill while they exist (see Azure Cost Management). Do not leave the baseline running; follow Clean-up when you are done.

Azure Verified Modules

Azure Verified Modules (AVM) is Microsoft’s library of infrastructure-as-code modules for Bicep and Terraform. Its goal, in its own words, is to define what a good module is: how it is built, how it is consumed and what it delivers. The program is owned by the Azure Deployments team in Azure engineering, and the modules are supported by Microsoft through GitHub issues in each module’s repository, with no response or resolution time commitments; platform problems go to Microsoft support, and security vulnerabilities go to the Microsoft Security Response Center. Terraform AVM modules are published to the Terraform Registry under the Azure namespace.

Class Naming What it is

Resource module

avm-res-<resource-provider>-<resource>, for example Azure/avm-res-storage-storageaccount/azurerm

One primary resource with its common child resources and a consistent set of interfaces.

Pattern module

avm-ptn-...

Several resources composed into an architecture.

Utility module

avm-utl-..., for example Azure/avm-utl-regions/azurerm

Helpers such as a list of regions, a naming convention or the shared interface code.

Source: Azure Verified Modules — Introduction, Azure Verified Modules — Terraform specifications and Azure Verified Modules — Module support. The naming column is taken from the module names in the AVM module index and the registry.

What an AVM module looks like

The Terraform specifications state that every new AVM Terraform module, whatever its class, must use the AzAPI provider for control plane resources, and allow azurerm only for a narrow exception for a data plane or non-ARM operation that AzAPI cannot do. The stated reasons are built-in retry and timeout handling, plan-time preflight validation, same-day access to new Azure features, and alignment with Bicep and ARM. The consequences for a user:

  • The caller configures provider "azapi" {} (and azurerm only for its own resources). The resource group module, for example, requires azapi and shows that azurerm should be configured in the caller only while upgrading from a module version that used it.

  • The inputs follow the AzAPI shape: the resource group module takes name and location, and the storage account module takes name, location and parent_id (the resource group’s resource ID, not its name) rather than resource_group_name.

  • The outputs include resource_id, name and resource.

  • A set of shared interfaces appears on most resource modules: lock, role_assignments, tags, diagnostic_settings, managed_identities, private_endpoints and customer_managed_key, implemented through the utility module Azure/avm-utl-interfaces/azure.

  • The module sends telemetry to Microsoft unless enable_telemetry = false; it uses the Azure/modtm provider for it. Set the variable to false where policy requires it.

  • The modules constrain Terraform and provider versions (the resource group module requires >= 1.9, < 2.0; the storage account module requires >= 1.10.0, and azapi ~> 2.12), so check the module’s terraform.tf before adopting it on an older Terraform.

Using a module

The following uses the resource group module and the storage account module in place of the azurerm resources for the covers account of the baseline. Because they are azapi-based, the provider configuration has azapi:

terraform {
  required_version = ">= 1.11"

  required_providers {
    azapi = {
      source  = "Azure/azapi"
      version = "~> 2.12"
    }
  }
}

provider "azapi" {}

variable "suffix" {
  type    = string
  default = "a1b2c3"
}

module "resource_group" {
  source  = "Azure/avm-res-resources-resourcegroup/azurerm"
  version = "0.4.0" # exact: pre-1.0 modules can break between minor versions

  name     = "rg-bookshelf"
  location = "eastus2"
  tags = {
    application = "bookshelf"
  }
}

module "covers" {
  source  = "Azure/avm-res-storage-storageaccount/azurerm"
  version = "0.10.0"

  name      = "stbookshelf${var.suffix}"
  location  = "eastus2"
  parent_id = module.resource_group.resource_id

  containers = {
    covers = {
      name = "covers"
    }
  }
}

The storage account module defaults shared_access_key_enabled to false, so data access is Entra ID only, and its containers input is a map whose value supports name, public_access (default None), metadata and role_assignments, among others. The module’s documentation says to leave the shared key disabled and to set storage_use_azuread = true in any azurerm provider block that touches the data plane.

Choosing, pinning and upgrading AVM modules

  • Check the status in the module index. The Terraform resource module index shows a status for each module. On the day this page was written, most of the modules checked were Available, and the Key Vault module (avm-res-keyvault-vault) was marked Orphaned, which means that it has no active owner and receives only essential maintenance from the AVM core team. An orphaned module is a risk for a long-lived environment; consider the azurerm resource or azapi directly.

  • Pin exactly. AVM’s process requires modules to be published as 0.x versions until the AVM team allows 1.0.0, and its versioning rules (written for Bicep modules; the Terraform specifications additionally require provider and CLI constraints that cap the major version) bump the minor version for a breaking change before 1.0.0. A constraint such as ~> 0.10 can therefore pick up a breaking change. Pin an exact version and upgrade deliberately, reading the module’s changelog and the plan.

  • Expect state moves. The storage account module’s documentation describes version 0.7.0 moving containers, queues, shares and tables from resources in the root module to child modules; without moved blocks, an upgrade from 0.6.x plans to destroy and recreate them. See Refactoring with moved and removed Blocks for the mechanism.

  • Adopt modules from the same family together. Interfaces such as role_assignments and diagnostic_settings behave the same across the family, which is the benefit of AVM over a collection of unrelated modules.

Pattern modules, such as the Azure landing zone modules, compose several resource modules and are the way to adopt a governed platform; the module index lists them. How to write your own modules, and the conventions that AVM follows, are in Module Development and Publishing, and finding and evaluating third-party modules in Modules. The corresponding Bicep modules are described in Azure Infrastructure as Code.

A GitHub Actions plan job

The pipeline needs no Azure secret: it holds three identifiers and a federated credential on the app registration. The job below runs a plan on a pull request; the full pipeline (formatting, linting, tests, a plan comment and an apply gated by an environment) is in CI/CD and Automation, and the quality gates in Code Quality and Security Scanning.

name: terraform-azure-plan

on:
  pull_request:
    paths: ["infra/**"]

permissions:
  contents: read
  id-token: write # lets the provider request the GitHub OIDC token

jobs:
  plan:
    runs-on: ubuntu-latest
    env:
      ARM_USE_OIDC: "true"
      ARM_USE_AZUREAD: "true" # azurerm backend: Microsoft Entra ID for the storage account
      ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
      ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      TF_IN_AUTOMATION: "true"
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_wrapper: false
      - run: terraform init -input=false -backend-config=backend.hcl
      - run: terraform validate
      - run: terraform plan -input=false -lock-timeout=5m -out=tfplan

The alternative is to sign in with azure/login@v3 (inputs client-id, tenant-id, subscription-id) and let the provider and the backend use the CLI session (use_cli, the provider default). That is the better fit when the job also runs az commands, for example to push an image to the registry. Pass only repository variables or secrets into those inputs, never values from the pull request, because the action’s README warns that untrusted values in these inputs can influence the identity the action logs in as. The current majors used here (actions/checkout v7, hashicorp/setup-terraform v4 and azure/login v3) were verified on the actions' pages; older tutorials use an older setup-terraform. The workflow identity should be a plan identity with read access for pull requests, and a separate identity, behind an environment with required reviewers, for the apply; Azure DevOps users follow the AzureCLI@2 pattern shown in the AzureRM OIDC guide. The governance side of what the pipeline is allowed to deploy is in Azure Governance and Compliance and in Azure Developer Tools and DevOps.

What changed recently

Version or date Change

Terraform 1.17 (2026-10-21), OpenTofu 1.13

Current baseline for this page.

AzureRM 5.0 (2026-07-27)

Major release; 5.11.0 is current (2026-10-23). resource_provider_registrations defaults to none (was legacy) and skip_provider_registration is removed. enhanced_validation moved into features and is disabled by default; a new preflight_enabled argument. Deprecated resources removed, among them azurerm_app_service, azurerm_app_service_plan, azurerm_function_app, the azurerm_postgresql_* single-server resources, azurerm_redis_enterprise_cluster and azurerm_redis_enterprise_database (replaced by azurerm_managed_redis). Behaviour changes include azurerm_key_vault.rbac_authorization_enabled becoming required, azurerm_storage_container using storage_account_id, azurerm_container_app_environment.logs_destination no longer computed, and minimum TLS versions of 1.0 and 1.1 no longer accepted on several resources. 5.0.1 (2026-07-30) added state migrations for azurerm_storage_queue and azurerm_storage_table_entity on the 4.x to 5.x upgrade path.

AzureRM 4.0 (2024-08-22)

subscription_id mandatory for plan and apply; resource_provider_registrations (core, extended, all, none, legacy) and resource_providers_to_register replace the all-or-nothing skip_provider_registration; provider functions normalise_resource_id and parse_resource_id; AKS moved to the stable API, removing some preview properties (use AzAPI for those); subnets in azurerm_virtual_network gained properties and address_prefix became address_prefixes; many enable_* properties renamed to *_enabled.

AzureRM 6.0

An upgrade guide skeleton exists on the provider’s main branch, but no 6.x release was on the registry when this page was written.

AzAPI 2.0

body and the exported output became native HCL values instead of JSON strings; provider functions; retry, replace_triggers_*, JMESPath in response_export_values, preflight validation; sensitive_body for write-only properties. 2.13.0 is current.

AzureAD 3.0 (2024-09-27)

Major release; 3.10.0 is current.

Azure Cache for Redis

The older service is being retired in favour of Azure Managed Redis (see the Azure section); AzureRM 5.0 removed the Redis Enterprise resources and offers azurerm_managed_redis. azurerm_redis_cache is still in the 5.x docs.

hashicorp/setup-terraform, actions/checkout, azure/login

Current majors verified: v4, v7 and v3 (the issue mentions v3 for setup-terraform).

Upgrading from AzureRM 3.x to 4.x to 5.x

The provider documents a path of one major at a time. Read the upgrade guide of each major before changing the constraint, and:

  1. Upgrade Terraform itself first; both upgrade guides recommend the latest Terraform.

  2. Pin the provider to the last release of the major you are on (for example = 4.81.0 before moving on) and apply, so that state is current and there is no pending drift.

  3. Change the constraint to the next major, run terraform init -upgrade, and read the plan. Expect replacement warnings that are in-place updates (the 4.0 guide says this about the virtual network subnets).

  4. Fix the removed arguments and resources the guide lists. Where a resource was replaced (for example the PostgreSQL single server by the flexible server, or Redis Enterprise by Managed Redis) the guide links a migration path; data migration is not something Terraform does for you.

  5. Set resource_provider_registrations explicitly in 5.0, as above.

  6. Commit the lock file, and run the pipeline’s plan before and after.

Source: Terraform Registry — AzureRM 5.0 upgrade guide. The 4.81.0 pin is taken from a link in the 5.0 guide; any late 4.x release works.

Where books and older tutorials are outdated

Older tutorials, including the AzureRM examples in many books, typically show features that no longer work as written: no subscription_id (mandatory since 4.0), skip_provider_registration = true (removed in 5.0), enable_* argument names (renamed to *_enabled), azurerm_app_service and azurerm_app_service_plan (removed in 5.0), azurerm_postgresql_server (removed), azurerm_storage_container with storage_account_name (removed), a Key Vault with access_policy blocks and no rbac_authorization_enabled (now required), and a service principal with a client secret in the provider block. Check the version of the provider in any example before running it.

Clean-up

Destroy the Bookshelf baseline when you are done; the compute, database, cache and network resources bill while they exist.

terraform plan -destroy -out=destroy.tfplan
terraform apply destroy.tfplan

Things that can get in the way or outlive the destroy:

  • prevent_deletion_if_contains_resources. Defaults to true, so a resource group that still contains a resource that Terraform does not manage (a diagnostic setting created by policy, a resource added by hand) refuses to be deleted. Remove the stray resources, or set it to false in the features block for the destroy run.

  • Soft-deleted Key Vault. The vault is soft-deleted and its name stays reserved until the retention period (7 to 90 days) ends. With purge_soft_delete_on_destroy = false, as in this page, it is not purged; set it to true (the documented default) and keep purge_protection_enabled = false to free the name immediately. A vault with purge protection cannot be purged until the period ends. The recover_soft_deleted_key_vaults setting then lets a later apply recover a vault with the same name.

  • Soft-deleted Log Analytics workspace. permanently_delete_on_destroy defaults to false, so a workspace of the same name cannot be recreated for the retention window unless it is purged.

  • Role assignments. Role assignments are destroyed with their scope; those created at a higher scope than the configuration (a subscription-wide Contributor for the pipeline identity) are not part of it, so remove them yourself.

  • Entra objects. App registrations, service principals and groups created through azuread are removed by the destroy, which needs the same Graph permissions as creating them.

  • State storage last. Destroy the baseline first, then, only when nothing else uses it, the storage account that held its state: az group delete --name rg-bookshelf-tfstate --yes. Do this deliberately and last; deleting the state account while a configuration still points at it leaves resources unmanaged.

  • As a last resort, az group delete --name rg-bookshelf --yes --no-wait removes a resource group and everything in it, including resources that Terraform created and that the state still lists; follow it with terraform state rm of the leftovers, or delete the state.

Common pitfalls

  • Forgetting subscription_id after upgrading to 4.0 or later. Plan fails; set ARM_SUBSCRIPTION_ID.

  • Relying on automatic provider registration after 5.0. New resources fail with an unregistered-provider error; list the providers in resource_providers_to_register.

  • Treating Contributor as enough. It cannot create role assignments; give the pipeline a more specific role at the right scope, and separate plan and apply identities.

  • The backend authenticates but cannot read the blob. The Entra identity needs a data plane role such as Storage Blob Data Contributor; Owner on the account is not enough, and a new assignment takes minutes.

  • The shared key is disabled but the provider still uses it. Set storage_use_azuread = true in the azurerm provider block and in azurerm data plane resources.

  • Globally unique names. Storage account names, container registry names, Key Vault names and PostgreSQL server names are global; use a suffix variable, and remember that a soft-deleted vault keeps its name.

  • Putting the state account in the configuration it holds. A destroy then deletes its own state.

  • Pinning AVM modules with ~>. Pre-1.0 minor versions can contain breaking changes; pin exactly.

  • Mixing AzAPI and AzureRM on one property. Drift appears at the next plan; put each property under one provider.

  • Putting secrets in body. Use sensitive_body or an ephemeral value so the secret is not stored in state.

  • Importing with the wrong casing. Resource IDs from the portal can differ in case from what the provider stores; normalise them (provider::azurerm::normalise_resource_id) before comparing or importing.

  • Long-lived client secrets in CI. Replace them with a federated credential; see CI/CD and Automation.