Home / Blog / Infrastructure as Code

Terraform on Azure: azurerm 4.x Setup and Keyless Remote State in Azure Storage

Configure the azurerm 4.x provider correctly and store Terraform state in an Azure Storage account secured with Entra ID instead of shared access keys, with blob versioning, soft delete and native lease-based locking.

TerraformAzureEntra IDIaC

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_id must 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 = false turns 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 = true in 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_id on the container is the azurerm 4.x way to link a container to its account. The older storage_account_name argument 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)"
Role assignments can take a few minutes to take effect. If 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_id or ARM_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 = true in the backend, or storage_use_azuread = true in 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 set resource_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.

Bassel Aasi
Bassel Aasi

Cloud engineer and PMP, certified as an Azure Solutions Architect Expert, AWS SysOps Administrator and Terraform Associate. I help businesses build secure, automated infrastructure on AWS and Azure.

Let's talk about your infrastructure

Tell me where you are and where you want to be. I'll reply within 24 hours with practical next steps.