title: Remote state backends description: Azure Storage backend configuration for Terraform / OpenTofu: blob lease locking, OIDC auth from CI, and the bootstrap pattern. tags: - terraform - azure
Remote state backends¶
The azurerm backend stores Terraform state as a blob in an Azure Storage
container. State locking is automatic via blob leases (no separate lock table
like AWS DynamoDB), and encryption-at-rest is on by default.
Use Azure AD auth, not storage keys
Setting use_azuread_auth = true makes the backend authenticate with your
Azure AD identity (CLI or workload identity) instead of a shared storage
account key. Pair it with the Storage Blob Data Contributor role on
the container.
Minimal backend block¶
terraform {
required_version = ">= 1.3"
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstateprod001" # globally unique, 3–24 lowercase
container_name = "tfstate"
key = "platform/network.tfstate"
use_azuread_auth = true # AAD instead of access keys
subscription_id = "00000000-0000-0000-0000-000000000000"
tenant_id = "11111111-1111-1111-1111-111111111111"
}
}
Locking
The azurerm backend acquires a blob lease on the state object for the duration of any operation that mutates state. If a previous run crashed, break the lease with:
OIDC from GitHub Actions¶
Federated credentials let CI authenticate without a long-lived secret. Set
use_oidc = true in the backend and export ARM_USE_OIDC=true in the
workflow; azurerm picks up ACTIONS_ID_TOKEN_REQUEST_TOKEN /
ACTIONS_ID_TOKEN_REQUEST_URL automatically.
terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstateprod001"
container_name = "tfstate"
key = "platform/network.tfstate"
use_azuread_auth = true
use_oidc = true
subscription_id = "00000000-0000-0000-0000-000000000000"
tenant_id = "11111111-1111-1111-1111-111111111111"
client_id = "22222222-2222-2222-2222-222222222222"
}
}
# .github/workflows/terraform.yml
permissions:
id-token: write # required for OIDC
contents: read
jobs:
plan:
runs-on: ubuntu-latest
env:
ARM_USE_OIDC: "true"
ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan
RBAC required on the state container¶
The identity running Terraform needs data-plane access on the blob
container, control-plane roles like Contributor are not enough when
use_azuread_auth = true.
resource "azurerm_role_assignment" "tfstate_writer" {
scope = azurerm_storage_container.tfstate.resource_manager_id
role_definition_name = "Storage Blob Data Contributor"
principal_id = data.azuread_service_principal.ci.object_id
}
| Role | Why |
|---|---|
| Storage Blob Data Contributor | Read / write state blobs and leases |
| Storage Blob Data Reader | terraform plan -refresh-only only |
Bootstrap pattern¶
You can't store the state of the storage account in the storage account itself. Solve it with a one-shot bootstrap module that runs against local state, then migrate it.
# bootstrap/main.tf : apply with local state, then `terraform state push`.
terraform {
required_version = ">= 1.3"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
resource "azurerm_resource_group" "tfstate" {
name = "tfstate-rg"
location = "eastus"
}
resource "azurerm_storage_account" "tfstate" {
name = "tfstateprod001"
resource_group_name = azurerm_resource_group.tfstate.name
location = azurerm_resource_group.tfstate.location
account_tier = "Standard"
account_replication_type = "GRS"
account_kind = "StorageV2"
min_tls_version = "TLS1_2"
shared_access_key_enabled = false # force AAD auth
allow_nested_items_to_be_public = false
blob_properties {
versioning_enabled = true # recover overwritten state
change_feed_enabled = true
delete_retention_policy { days = 30 }
container_delete_retention_policy { days = 30 }
}
}
resource "azurerm_storage_container" "tfstate" {
name = "tfstate"
storage_account_id = azurerm_storage_account.tfstate.id
container_access_type = "private"
}
Workflow:
cd bootstrap && terraform init && terraform apply(local state).- Add the
backend "azurerm"block to the bootstrap module. terraform init -migrate-state: Terraform copies the local state into the new container.- Commit; never apply the bootstrap module from CI again.
Protect the storage account
Enable blob versioning, soft delete (≥ 30 days), and a resource lock
(azurerm_management_lock with lock_level = "CanNotDelete"). Losing
state for a production environment is much harder to recover from than
losing infrastructure.