The Azure Provider
The azurerm provider lets Terraform manage resources in Microsoft Azure — virtual networks, storage accounts, AKS clusters, App Services, and hundreds more. It is one of the most heavily used providers in the registry and has a few conventions that trip up newcomers, most notably the mandatory features {} block and the way Azure groups resources. This page walks through declaring the provider, authenticating against Azure, and provisioning a small but realistic stack. Everything here works identically under OpenTofu, which consumes the same provider binary from the registry.
Declaring and pinning the provider
Like every provider, azurerm is declared in required_providers so the version is locked in your lockfile. Pin to a major version and let patch releases float — Azure ships breaking changes between majors fairly often.
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
The features {} block is required even when empty — the provider will not initialize without it. It exists so you can opt into per-resource-type behaviours, such as whether deleting a resource group also purges nested resources, or whether soft-deleted Key Vaults are recovered automatically.
provider "azurerm" {
subscription_id = var.subscription_id
features {
resource_group {
prevent_deletion_if_contains_resources = true
}
key_vault {
purge_soft_delete_on_destroy = false
}
}
}
As of provider v4,
subscription_idis mandatory and is no longer inferred silently from the environment. Set it explicitly via the argument or theARM_SUBSCRIPTION_IDenvironment variable, orterraform planwill fail fast.
Authentication
The provider supports several auth methods. It searches them in a fixed order, so the first one it finds wins.
| Method | Best for | How it’s supplied |
|---|---|---|
| Azure CLI | Local development | az login, then nothing in the config |
| Service principal + secret | CI/CD pipelines | ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID |
| Service principal + certificate | Hardened CI/CD | client_certificate_path + password |
| Managed identity | Code running on Azure VMs/runners | use_msi = true |
| OIDC / workload identity | GitHub Actions, GitLab | use_oidc = true |
Azure CLI (local)
The simplest path for development is to authenticate the CLI and let the provider reuse that session. No credentials live in your Terraform files.
az login
az account set --subscription "00000000-0000-0000-0000-000000000000"
terraform init && terraform plan
Service principal (CI/CD)
For automation, create a service principal and feed its credentials through environment variables — never hard-code secrets in .tf files.
az ad sp create-for-rbac --name "tf-ci" --role Contributor \
--scopes /subscriptions/00000000-0000-0000-0000-000000000000
export ARM_CLIENT_ID="11111111-1111-1111-1111-111111111111"
export ARM_CLIENT_SECRET="<generated-secret>"
export ARM_TENANT_ID="22222222-2222-2222-2222-222222222222"
export ARM_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000"
With those exported, the provider block stays minimal — just features {} — because every other value is read from the environment.
Resource groups: the unit of organisation
In Azure, almost every resource lives inside a resource group, which acts as a deletion and lifecycle boundary. You declare the group once and reference its name and location from the resources you nest inside it.
resource "azurerm_resource_group" "main" {
name = "rg-devcraftly-prod"
location = "West Europe"
tags = {
environment = "production"
managed_by = "terraform"
}
}
Worked example: a storage account in a resource group
This stack creates a resource group and a globally-unique storage account inside it, then exports the primary connection string as a sensitive output. Storage account names must be 3-24 lowercase alphanumeric characters and unique across all of Azure, so a random_string suffix keeps re-runs collision-free.
resource "random_string" "suffix" {
length = 6
upper = false
special = false
}
resource "azurerm_resource_group" "main" {
name = "rg-devcraftly-prod"
location = "West Europe"
}
resource "azurerm_storage_account" "assets" {
name = "stdevcraftly${random_string.suffix.result}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = "LRS"
min_tls_version = "TLS1_2"
blob_properties {
versioning_enabled = true
}
tags = {
environment = "production"
}
}
output "storage_connection_string" {
value = azurerm_storage_account.assets.primary_connection_string
sensitive = true
}
Running terraform apply produces output along these lines:
Output:
Terraform will perform the following actions:
# azurerm_storage_account.assets will be created
+ resource "azurerm_storage_account" "assets" {
+ account_replication_type = "LRS"
+ account_tier = "Standard"
+ name = "stdevcraftlya1b2c3"
+ min_tls_version = "TLS1_2"
}
Plan: 3 to add, 0 to change, 0 to destroy.
azurerm_resource_group.main: Creation complete after 4s
azurerm_storage_account.assets: Creation complete after 22s
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
storage_connection_string = <sensitive>
Best Practices
- Always include
features {}and setsubscription_idexplicitly — both are required by provider v4 and omitting them failsinit/plan. - Keep credentials out of
.tffiles; useaz loginlocally andARM_*environment variables (or OIDC) in CI. - Pin
azurermwith~> 4.0and commit.terraform.lock.hclso every run uses the same binary across Terraform and OpenTofu. - Group resources by lifecycle into resource groups, and reference
azurerm_resource_group.<name>.location/.namerather than repeating literals. - Tag every resource consistently (
environment,managed_by) to make cost reporting and cleanup tractable. - Mark connection strings, keys, and passwords as
sensitive = trueso they never print to the console or logs.