Terraform on Azure feels familiar if you come from AWS, but there are a few Azure-specific details that trip people up: the mandatory features block, the subscription ID that version 4 of the azurerm provider now requires, and how storage account keys interact with remote state. This guide sets up a solid Azure foundation: the provider configured correctly, a hardened storage account for state, and authentication through Microsoft Entra ID instead of shared access keys.
What you need
- Terraform 1.11 or newer
- The Azure CLI, signed in with
az login - Owner, or Contributor plus User Access Administrator, on the target subscription (needed to assign the storage data role in Step 3)
Confirm which subscription you are working in before you create anything:
az account show --query "{name:name, id:id, tenant:tenantId}" -o table
az account set --subscription "<subscription-id>"
Step 1: Configure the azurerm provider
terraform {
required_version = ">= 1.11"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
# Required since azurerm 4.0. You can also set the ARM_SUBSCRIPTION_ID environment variable.
subscription_id = var.subscription_id
# Use Entra ID (not account keys) when Terraform talks to storage data planes
storage_use_azuread = true
}
variable "subscription_id" {
type = string
description = "Target Azure subscription ID"
}
Two lines here cause most of the confusion:
features {}is mandatory, even when empty. It is where you tune provider behaviour, such as whether deleting a resource group that still contains resources should fail.subscription_idmust be set explicitly from version 4.0. Earlier versions quietly used the CLI's default subscription, which is how people ended up deploying into the wrong one.
Step 2: Bootstrap the state storage account
As on AWS, the state storage must exist before Terraform can use it. Create it from a small bootstrap configuration that uses local state:
# bootstrap/main.tf (plus the terraform/provider blocks from Step 1)
data "azurerm_client_config" "current" {}
resource "azurerm_resource_group" "tfstate" {
name = "rg-tfstate-cc"
location = "canadacentral"
}
resource "azurerm_storage_account" "tfstate" {
# 3-24 characters, lowercase letters and numbers only, globally unique
name = "sttfstate${substr(replace(data.azurerm_client_config.current.subscription_id, "-", ""), 0, 12)}"
resource_group_name = azurerm_resource_group.tfstate.name
location = azurerm_resource_group.tfstate.location
account_tier = "Standard"
account_replication_type = "GRS"
min_tls_version = "TLS1_2"
https_traffic_only_enabled = true
allow_nested_items_to_be_public = false
shared_access_key_enabled = false # Entra ID only, no account keys
blob_properties {
versioning_enabled = true
delete_retention_policy {
days = 30
}
container_delete_retention_policy {
days = 30
}
}
lifecycle {
prevent_destroy = true
}
}
resource "azurerm_storage_container" "tfstate" {
name = "tfstate"
storage_account_id = azurerm_storage_account.tfstate.id
container_access_type = "private"
}
output "storage_account_name" {
value = azurerm_storage_account.tfstate.name
}
What these settings do:
shared_access_key_enabled = falseturns off the account keys entirely. Anyone with a key has full access to every blob, so removing keys means all access goes through Entra ID and RBAC, where it is audited and can be revoked.storage_use_azuread = truein the provider block is what lets Terraform create the container once keys are disabled. Without it, the container creation fails with a 403 error.- Blob versioning and soft delete let you recover an overwritten or deleted state file for 30 days.
storage_account_idon the container is the azurerm 4.x way to link a container to its account. The olderstorage_account_nameargument is deprecated.
Step 3: Grant data-plane access
This is the step that catches people out. Being Owner of the subscription lets you manage the storage account, but it doesn't let you read blobs through Entra ID. That needs a data-plane role:
resource "azurerm_role_assignment" "tfstate_me" {
scope = azurerm_storage_account.tfstate.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = data.azurerm_client_config.current.object_id
}
Then apply the bootstrap:
cd bootstrap
terraform init
terraform apply -var="subscription_id=$(az account show --query id -o tsv)"
terraform apply fails creating the container with a 403 error, wait a moment and run it again.Step 4: Use the backend in your projects
# infra/networking/backend.tf
terraform {
backend "azurerm" {
resource_group_name = "rg-tfstate-cc"
storage_account_name = "sttfstate0123456789ab"
container_name = "tfstate"
key = "networking/prod.tfstate"
use_azuread_auth = true
}
}
use_azuread_auth = true tells the backend to use your Entra ID identity, which matters because account keys are disabled. Locking needs no extra setup: the azurerm backend takes a blob lease on the state file during every operation, so two runs can never write at once.
Initialise and check it:
terraform init
terraform plan -var="subscription_id=$(az account show --query id -o tsv)"
Step 5: Deploy something real
With the foundation in place, here is a small but realistic networking stack: a virtual network with a subnet locked down by a network security group.
resource "azurerm_resource_group" "net" {
name = "rg-network-prod-cc"
location = "canadacentral"
tags = {
environment = "prod"
managed_by = "terraform"
}
}
resource "azurerm_virtual_network" "main" {
name = "vnet-prod-cc"
resource_group_name = azurerm_resource_group.net.name
location = azurerm_resource_group.net.location
address_space = ["10.20.0.0/16"]
}
resource "azurerm_subnet" "app" {
name = "snet-app"
resource_group_name = azurerm_resource_group.net.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.20.1.0/24"]
}
resource "azurerm_network_security_group" "app" {
name = "nsg-app"
resource_group_name = azurerm_resource_group.net.name
location = azurerm_resource_group.net.location
security_rule {
name = "allow-https-in"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "Internet"
destination_address_prefix = "*"
}
}
resource "azurerm_subnet_network_security_group_association" "app" {
subnet_id = azurerm_subnet.app.id
network_security_group_id = azurerm_network_security_group.app.id
}
Note that the subnet and NSG are linked with a separate association resource. Defining the subnet inline inside azurerm_virtual_network as well is a common way to get Terraform fighting itself on every plan.
Common errors
- "subscription_id is a required provider property": you are on azurerm 4.x and haven't set
subscription_idorARM_SUBSCRIPTION_ID. - 403 "This request is not authorized to perform this operation" on state: your identity is missing Storage Blob Data Contributor on the storage account, or the assignment hasn't propagated yet.
- "Key based authentication is not permitted": you disabled shared keys but forgot
use_azuread_auth = truein the backend, orstorage_use_azuread = truein the provider. - Resource provider not registered: azurerm registers a core set of resource providers automatically. In locked-down subscriptions, register them yourself (for example
az provider register --namespace Microsoft.Network) and setresource_provider_registrations = "none"in the provider block.
Wrapping up
You now have Terraform on Azure set up the same way as on AWS: remote state that is versioned, locked and protected by Entra ID instead of shared keys. The final piece is automation. In the next article I connect both clouds to GitHub Actions using OIDC federation, so the pipeline can run terraform plan and apply without a single stored secret.