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 |
|---|---|---|
|
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, |
|
Any ARM resource type at any API version through generic resources ( |
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 |
|
Microsoft Entra ID (formerly Azure Active Directory) objects through Microsoft Graph: applications, service principals, groups, users, federated identity credentials. |
Authenticated separately from |
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
azurermby 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
azapiwhenazurermdoes 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
azurermand extend it withazapi_update_resource. Changes made byazapi_update_resourceto 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 |
|
A developer workstation. Requires the |
OpenID Connect (workload identity federation) |
|
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 |
|
Terraform running on an Azure VM, a container or a self-hosted agent that has an identity attached. |
AKS workload identity |
|
Terraform running as a pod; |
Service principal and client secret |
|
Legacy. A secret that you must rotate and keep out of state, logs and plans. |
Service principal and client certificate |
|
Legacy, for the same reasons. |
Source: Terraform Registry — AzureRM provider, argument reference and Terraform Registry — AzureRM: authenticating using managed identities.
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
Contributorat the scope that Terraform manages. A Terraform configuration that also creates role assignments needs permission to write them, whichContributordoes not include; the managed identity guide says to useOwnerorContributortogether withUser Access Administratorwhen 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 Contributoron the state container (see The azurerm backend), and a configuration that writes Key Vault secrets needs a Key Vault data role such asKey Vault Secrets Officer. -
Microsoft Graph, for
azuread: with a service principal,azuread_applicationneeds the application roleApplication.ReadWrite.OwnedByorApplication.ReadWrite.All; withOwnedBy, the Terraform principal must be an owner of the application.azuread_groupneedsGroup.CreateplusUser.Read.All,Application.Read.AllorDirectory.Read.Allwhen 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 |
|---|---|---|
|
|
|
|
Permanently deletes (purges) the vault after destroying it, which frees the globally unique name. |
|
|
When creating a vault whose name belongs to a soft-deleted one, recover that vault rather than failing. |
|
|
Permanently deletes the workspace rather than leaving it soft-deleted. |
|
|
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_registrationsselects 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) ornone(register nothing). It can also come fromARM_RESOURCE_PROVIDER_REGISTRATIONS. -
resource_providers_to_registeris 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), defaultfalse: use Entra ID instead of the account key for the Blob and Queue data plane. It is required in practice when a storage account hasshared_access_key_enabled = false, and it needs theStoragedata 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_idanddisable_terraform_partner_id: usage attribution. By default the provider sends a Terraform partner ID to Microsoft; setdisable_terraform_partner_id(ARM_DISABLE_TERRAFORM_PARTNER_ID) totrueto stop it. The partner ID gives HashiCorp no access to usage information, according to the documentation. -
environmentandmetadata_host: sovereign and custom clouds. -
auxiliary_tenant_ids: up to three other tenants for cross-tenant scenarios. -
Several
azurermprovider blocks withaliaswork 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 |
|---|---|
|
|
|
A dynamic HCL object that is the request body. Since AzAPI 2.0 it is not a JSON string, so no |
|
Which parts of the response become the read-only |
|
Managed identity and tags, with the same shape as in |
|
A write-only part of the body (not stored in state, Terraform 1.11 or later), resent only when its version changes. |
|
Validates |
|
Tuning of the diff between the configuration and what the API returns, such as properties that the API never returns. |
|
Force replacement when a value or a property of the configuration changes. |
|
Retry on matching error messages, operation timeouts, and ARM resource IDs to serialise operations on. |
|
Extra headers and query parameters for the request. |
Source: Terraform Registry — azapi_resource.
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>andaztfexport query <Azure Resource Graph predicate>. It defaults toazurerm;--provider-name=azapiexports AzAPI resources instead. Telemetry is on by default and is turned off withaztfexport 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 theimportblock that Terraform itself provides. -
aztfmigratemigrates 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
}
Source: Terraform Registry — AzureAD provider, Terraform Registry — azuread_application_federated_identity_credential and Terraform Registry — azurerm_role_assignment.
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) |
|
SAS token (not recommended for new workloads) |
|
Access key (not recommended) |
|
Access key lookup (not recommended) |
Authenticates to ARM with an Entra principal and looks up the account key; needs |
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 Contributorto the pipeline identity at the scope of the container, andReaderonly 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_vaultkey 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.
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
}
Source: azurerm_subnet, azurerm_private_dns_zone_virtual_network_link, azurerm_log_analytics_workspace, azurerm_container_registry and Microsoft Learn — Configure virtual networks in Azure Container Apps environments.
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_woandvalue_woaccept the ephemeral value; the_wo_versioncompanions 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_enabledis required onazurerm_key_vaultsince 5.0. The vault uses Azure RBAC rather than access policies; the Terraform identity is givenKey Vault Secrets Officerexplicitly, 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_containertakesstorage_account_idsince 5.0; the oldstorage_account_nameargument was removed. -
With
shared_access_key_enabled = falsethe provider block needsstorage_use_azuread = true, as shown above. -
The PostgreSQL
zoneis reassigned by Azure on a failover, so it is excluded withignore_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
portattribute. 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 argumentpublic_network_accessdefaults toEnabled); 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.tfvarsfile 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
fqdnof the server, the Redishostname) are fine inenvblocks: 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 |
|
One primary resource with its common child resources and a consistent set of interfaces. |
Pattern module |
|
Several resources composed into an architecture. |
Utility module |
|
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" {}(andazurermonly for its own resources). The resource group module, for example, requiresazapiand shows thatazurermshould 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
nameandlocation, and the storage account module takesname,locationandparent_id(the resource group’s resource ID, not its name) rather thanresource_group_name. -
The outputs include
resource_id,nameandresource. -
A set of shared interfaces appears on most resource modules:
lock,role_assignments,tags,diagnostic_settings,managed_identities,private_endpointsandcustomer_managed_key, implemented through the utility moduleAzure/avm-utl-interfaces/azure. -
The module sends telemetry to Microsoft unless
enable_telemetry = false; it uses theAzure/modtmprovider for it. Set the variable tofalsewhere 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, andazapi~> 2.12), so check the module’sterraform.tfbefore 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"
}
}
}
Source: Terraform Registry — avm-res-resources-resourcegroup, Terraform Registry — avm-res-storage-storageaccount and the modules' repositories terraform-azurerm-avm-res-resources-resourcegroup and terraform-azurerm-avm-res-storage-storageaccount. Module versions are those on the registry on 2026-10-23.
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 markedOrphaned, 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 theazurermresource orazapidirectly. -
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.10can 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
movedblocks, 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_assignmentsanddiagnostic_settingsbehave 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
Source: Terraform Registry — AzureRM: authenticating using a service principal with OpenID Connect, hashicorp/setup-terraform and Terraform Docs — Backend type: azurerm.
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). |
AzureRM 4.0 (2024-08-22) |
|
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 |
|
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 |
|
Current majors verified: v4, v7 and v3 (the issue mentions v3 for |
Source: Terraform Registry — AzureRM 5.0 upgrade guide, Terraform Registry — AzureRM 4.0 upgrade guide, AzureRM CHANGELOG, AzAPI 2.0 upgrade guide and AzureAD releases.
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:
-
Upgrade Terraform itself first; both upgrade guides recommend the latest Terraform.
-
Pin the provider to the last release of the major you are on (for example
= 4.81.0before moving on) and apply, so that state is current and there is no pending drift. -
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). -
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.
-
Set
resource_provider_registrationsexplicitly in 5.0, as above. -
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
Source: Terraform Docs — terraform plan.
Things that can get in the way or outlive the destroy:
-
prevent_deletion_if_contains_resources. Defaults totrue, 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 tofalsein thefeaturesblock 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 totrue(the documented default) and keeppurge_protection_enabled = falseto free the name immediately. A vault with purge protection cannot be purged until the period ends. Therecover_soft_deleted_key_vaultssetting then lets a later apply recover a vault with the same name. -
Soft-deleted Log Analytics workspace.
permanently_delete_on_destroydefaults tofalse, 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
Contributorfor the pipeline identity) are not part of it, so remove them yourself. -
Entra objects. App registrations, service principals and groups created through
azureadare 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-waitremoves a resource group and everything in it, including resources that Terraform created and that the state still lists; follow it withterraform state rmof the leftovers, or delete the state.
Common pitfalls
-
Forgetting
subscription_idafter upgrading to 4.0 or later. Plan fails; setARM_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
Contributoras 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;Owneron 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 = truein theazurermprovider block and inazurermdata 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. Usesensitive_bodyor 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.
References
-
Terraform Registry — AzureRM: authenticating using the Azure CLI
-
Terraform Registry — AzureRM: authenticating using a service principal with OpenID Connect
-
Terraform Registry — AzureRM: authenticating using managed identities
-
Terraform Registry — azuread_application_federated_identity_credential
-
Microsoft Learn — Configure virtual networks in Azure Container Apps environments