Skip to main content
RunBook Academy

TerraformXXVI · Cloud and Platform OperationsProduction Terraform

Terraform on Azure

Intermediate⏱ ~18 minbash

What you'll learn

  • Configure the hashicorp/azurerm provider 4.x with the resource-group-first pattern
  • Compose a minimal Azure landing zone: resource group, VNet, subnet, NSG, Linux VM
  • Authenticate Terraform to Azure with a service principal and OIDC for CI
  • Wire a managed identity into the CI runner so credentials are never stored long-term
  • Apply Azure Policy and tags consistently via the provider

Prerequisites

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

Azure is the second-largest target for production Terraform. The AzureRM provider is on the 4.x line as of August 2026. The 4.x release was a breaking change: it removed azuread from the provider, removed several legacy SKUs, and forced explicit resource-group declarations on every resource. This lesson covers the production shape: provider pinning, the resource-group-first pattern, service-principal and OIDC authentication, and a minimal landing zone.

The resource-group-first pattern

Azure ARM is hierarchical. Every resource lives in a resource group; the resource group lives in a subscription; the subscription lives in a management group. Terraform on Azure mirrors that hierarchy.

Tenant
  └── Management Group
        └── Subscription
              └── Resource Group
                    ├── VNet
                    ├── Subnets
                    ├── NSGs
                    ├── VMs
                    ├── Disks
                    ├── Public IPs
                    └── ...

The AzureRM 4.x provider enforces that every resource declares the resource group it belongs to. There is no implicit “current resource group.” This is a feature: the apply fails loudly if a resource is moved or referenced against the wrong group.

terraform {
  required_version = ">= 1.9.0, < 2.0.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.15"
    }
  }

  backend "azurerm" {
    resource_group_name  = "rg-tfstate-prod"
    storage_account_name = "sacmesteprod"
    container_name       = "tfstate"
    key                  = "platform.terraform.tfstate"
    use_azuread_auth     = true
  }
}

use_azuread_auth = true is the AzureRM 4.x replacement for the old storage-account-key authentication. With it, the CI runner authenticates to the storage account with an Azure AD token, not a shared key. There is no secret to rotate; access is controlled by RBAC.

Authentication: service principal and OIDC

The 4.x provider supports three authentication modes for human operators and one for CI:

  1. Azure CLI — the operator runs az login interactively. provider "azurerm" picks up the CLI token. Fine for desktops.
  2. Service principal with client secret — a SP is created with a client ID and client secret. The secret is stored in a secrets manager. Legacy pattern; not recommended for new deployments.
  3. Service principal with certificate — a SP is created with a client certificate. Better than a secret but still requires storing the certificate somewhere.
  4. OIDC (workload identity federation) — the CI platform asserts an OIDC token; Azure trusts the assertion and issues a short-lived Azure AD token. No secret stored anywhere. This is the production pattern.
provider "azurerm" {
  subscription_id = "00000000-0000-0000-0000-000000000000"
  tenant_id       = "11111111-1111-1111-1111-111111111111"

  use_oidc = true

  oidc_token_file_path = "/tmp/azure_workload_identity_token"
}

The CI runner (GitHub Actions, GitLab CI, Azure DevOps) writes the OIDC token to the file path; the provider exchanges it for an Azure AD token at apply time. The token has a TTL of an hour. No long- lived credential to leak.

For GitHub Actions specifically, the configuration is:

permissions:
  id-token: write
  contents: read

steps:
  - uses: azure/login@v2
    with:
      client-id: ${{ secrets.AZURE_CLIENT_ID }}
      tenant-id: ${{ secrets.AZURE_TENANT_ID }}
      subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      enable-oidc-true: true

The client-id, tenant-id, and subscription-id are not secrets; they are identifiers. The actual authentication is the OIDC token issued by GitHub’s identity provider and trusted by the federated credential on the Azure AD app registration.

The managed identity for the CI runner

The most common production mistake on Azure is to give the SP used by Terraform the Contributor role on the subscription. That is “Contributor” — full write to every resource — applied to every apply, including ad-hoc dev branches.

resource "azurerm_role_assignment" "tfstate_reader" {
  scope                = data.azurerm_storage_account.tfstate.id
  role_definition_name = "Storage Blob Data Reader"
  principal_id         = azuread_service_principal.ci.object_id
}

resource "azurerm_role_assignment" "tfstate_writer" {
  scope                = data.azurerm_storage_account.tfstate.id
  role_definition_name = "Storage Blob Data Contributor"
  principal_id         = azuread_service_principal.ci.object_id
}

The CI principal gets exactly the role it needs: read and write to the state container, nothing else. Other roles (Contributor on the subscription, or User Access Administrator) are granted to human operators via Azure AD groups, not to the Terraform principal.

For the resources Terraform creates, scope the role assignment to the resource group, not the subscription:

resource "azurerm_role_assignment" "prod_contributor" {
  scope                = azurerm_resource_group.prod.id
  role_definition_name = "Contributor"
  principal_id         = azuread_service_principal.ci.object_id
}

A minimal landing zone

resource "azurerm_resource_group" "prod" {
  name     = "rg-prod-platform"
  location = "uksouth"

  tags = {
    ManagedBy   = "Terraform"
    Environment = "production"
    Owner       = "platform@example.com"
    Repository  = "github.com/acme/infra"
  }
}

resource "azurerm_virtual_network" "main" {
  name                = "vnet-prod"
  location            = azurerm_resource_group.prod.location
  resource_group_name = azurerm_resource_group.prod.name
  address_space       = ["10.1.0.0/16"]

  tags = azurerm_resource_group.prod.tags
}

resource "azurerm_subnet" "app" {
  name                 = "snet-app"
  resource_group_name  = azurerm_resource_group.prod.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.1.1.0/24"]
}

resource "azurerm_network_security_group" "app" {
  name                = "nsg-app"
  location            = azurerm_resource_group.prod.location
  resource_group_name = azurerm_resource_group.prod.name

  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
}

Notice the tags = azurerm_resource_group.prod.tags pattern. Tags propagate by reference; every resource inherits the resource group’s tag set. If you add a tag at the resource-group level, it is inherited automatically. This is the Azure equivalent of default_tags in the AWS provider, but Azure’s inheritance works per-resource, not at the provider level.

Linux VM with managed identity

resource "azurerm_linux_virtual_machine" "app" {
  name                = "vm-app-01"
  resource_group_name = azurerm_resource_group.prod.name
  location            = azurerm_resource_group.prod.location
  size                = "Standard_D2s_v5"
  admin_username      = "azureuser"

  disable_password_authentication = true

  network_interface_ids = [
    azurerm_network_interface.app.id,
  ]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Premium_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "ubuntu-24_04-lts"
    sku       = "server"
    version   = "latest"
  }
}

resource "azurerm_managed_identity" "app" {
  name                = "id-app-01"
  resource_group_name = azurerm_resource_group.prod.name
  location            = azurerm_resource_group.prod.location
}

The VM does not have an admin password. Authentication is via SSH public key (managed outside Terraform). The managed identity is separate; it is the way the application on the VM authenticates to Azure Key Vault, to storage accounts, and to other Azure resources without storing credentials on disk.

How to validate

# READ-ONLY: confirm the provider and Terraform versions
terraform version
terraform providers

# READ-ONLY: confirm the runner has an OIDC token
cat /tmp/azure_workload_identity_token | head -c 50; echo

# CONFIGURATION: format and validate
terraform fmt -recursive
terraform validate

# CONFIGURATION: plan
terraform plan -out=tfplan

# READ-ONLY: inspect the plan for unexpected changes
terraform show tfplan

For an ad-hoc operator with the Azure CLI:

# READ-ONLY: confirm the active identity
az account show

# CONFIGURATION: initialise against the Azure backend
terraform init

# CONFIGURATION: format and validate
terraform fmt -recursive && terraform validate

# CONFIGURATION: plan
terraform plan -out=tfplan

# CONFIGURATION: apply after review
terraform apply tfplan

Production failure modes

  1. Resource moved between groups without state editing. An operator moves a resource in the portal; the next plan shows a destroy-and-create. The fix is to import the resource under the new resource group and remove it from the old group in state.
  2. State container deleted without soft delete. Storage account soft delete is not enabled by default in 4.x. A terraform destroy on a stack that owns the state container wipes the state. Enable soft delete (7-day retention minimum) and versioning on the storage account.
  3. Role assignment at subscription scope to the CI principal. A leaked secret gives full account access. The fix is OIDC, scoped role assignments, and short-lived tokens.
  4. Source image version pinned to latest in production. version = "latest" is a moving target. The CI might apply today against 24.04.202507010; tomorrow the plan proposes to rebuild every VM. Pin the image version explicitly.
  5. NSG rule with source_address_prefix = "*". A wildcard source on an inbound rule is a wildcard source. Tighten to the service tag (Internet, AzureLoadBalancer) or to a specific CIDR.
  6. Private endpoint not configured for the storage account. The state container has a public endpoint by default. A leaked container name plus a misconfigured firewall is a state leak. Configure a private endpoint and network_rules to deny public access.

What to do in production

  • Use OIDC for every CI principal. Never store a client secret in a GitHub Actions secret.
  • Scope role assignments to the smallest viable scope: resource group, not subscription.
  • Use managed identities on every VM and Azure service that needs to authenticate to Azure. The identity is the credential.
  • Pin source image versions in production. The latest filter is for dev only.
  • Configure the state storage account with soft delete, versioning, and a private endpoint. State is the secrets store.

Verification

After working through this lesson, confirm the following:

  • You can describe the resource-group-first pattern and why the 4.x provider made it mandatory.
  • You can configure OIDC for Terraform on Azure and explain why a client secret is no longer appropriate for CI.
  • You can write a minimal landing zone (resource group, VNet, subnet, NSG, Linux VM) from memory.
  • You can name two Azure defaults that produce security or operational incidents when left in place.

Knowledge check · 7 questions

  1. Q1. What is the production-grade authentication pattern for Terraform on Azure CI in 2026?

  2. Q2. Which of the following are mandatory defaults for a production landing zone on Azure? (Select all that apply.)

  3. Q3. Granting Contributor at the subscription scope to a CI service principal is acceptable if the secret is stored in a Key Vault.

  4. Q4. Why does the AzureRM 4.x provider require every resource to declare its resource group explicitly?

  5. Q5. What is the role of a managed identity on an Azure VM?

  6. Q6. A team's storage account has public network access enabled, soft delete disabled, and no versioning. A teammate runs terraform destroy on a stack that includes a resource group which contains that storage account. What happens?

  7. Q7. Why is pinning source_image_reference.version = 'latest' dangerous in production?

Passing score: 75%. Answers are checked in this browser.