Terraform Lesson 32 – Terraform with Azure | Dataplexa
Section III · Lesson 32

Terraform with Azure

The Azure provider (azurerm) is the second most widely used Terraform provider. Its resource model differs fundamentally from AWS — every resource must belong to a resource group, authentication uses service principals or managed identities, and naming has stricter constraints. This lesson covers the Azure provider from authentication through to the resource patterns you will use in every real Azure deployment.

This lesson covers

How Azure differs from AWS → azurerm provider authentication → Resource groups as a required container → Service principals and managed identities → Key azurerm resource patterns → Multi-subscription deployments → Azure RBAC with Terraform

How Azure Differs from AWS

Engineers who apply AWS patterns directly to Azure get confused. Azure has a distinct resource model that makes more sense once you understand its design principles. This table is the mental map.

Concept AWS Azure
Resource container Account (implicit) Resource Group (explicit, required on every resource)
Billing boundary AWS Account Azure Subscription
CI/CD identity IAM Role (assume_role) Service Principal or Managed Identity
Access management IAM Roles and Policies Azure RBAC with Role Assignments
Encryption keys AWS KMS Azure Key Vault
VM networking Network config inline on EC2 Separate NIC resource attached to VM

Azure Provider Authentication

The azurerm provider supports multiple authentication methods. The correct method depends on where Terraform runs — locally, in GitHub Actions, in Azure Pipelines, or on Azure-hosted compute.

New terms:

  • Service Principal — an Azure Active Directory (Entra ID) identity for non-human access. Has a client ID (like a username) and a client secret or certificate (like a password). Used in CI/CD pipelines on non-Azure infrastructure.
  • Managed Identity — an Azure-managed identity automatically assigned to Azure resources (VMs, App Service, Azure DevOps agents). No credentials to manage — Azure handles everything. Preferred for Azure-hosted CI/CD. Equivalent to an AWS IAM instance profile.
  • ARM_ environment variables — the four environment variables the azurerm provider reads automatically: ARM_TENANT_ID, ARM_SUBSCRIPTION_ID, ARM_CLIENT_ID, ARM_CLIENT_SECRET. When all four are set, no provider credentials arguments are needed.
  • features {} — a required block on every azurerm provider, even when empty. Without it the provider fails with a configuration error. It configures optional provider-level behaviours. Never omit it.
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0"
    }
  }
}

# ── Method 1: Azure CLI — local development ──────────────────────────────────
provider "azurerm" {
  features {}  # Required — always include, even when empty

  subscription_id = var.subscription_id
  # No credentials — uses identity from `az login`
}

# ── Method 2: Service Principal with client secret — CI/CD ──────────────────
provider "azurerm" {
  features {}

  tenant_id       = var.tenant_id        # Azure AD tenant ID
  subscription_id = var.subscription_id  # Target subscription
  client_id       = var.client_id        # Service principal application ID
  client_secret   = var.client_secret    # SP secret — sensitive, store in CI/CD secrets
}

# ── Method 3: ARM_ environment variables — recommended for CI/CD ─────────────
# Set in pipeline secrets:
# ARM_TENANT_ID       = your-tenant-uuid
# ARM_SUBSCRIPTION_ID = your-subscription-uuid
# ARM_CLIENT_ID       = your-sp-application-uuid
# ARM_CLIENT_SECRET   = your-sp-secret-value

provider "azurerm" {
  features {}
  # All four values read from ARM_ environment variables automatically
  # No credential arguments needed when all four env vars are set
}

# ── Method 4: Managed Identity — Azure-hosted CI/CD ─────────────────────────
provider "azurerm" {
  features {}
  use_msi         = true              # Use Managed Service Identity
  subscription_id = var.subscription_id
  # No client_id or client_secret — Azure provides the identity automatically
}

Resource Groups — The Container for Everything

Every Azure resource must belong to a resource group. This is not optional — it is the Azure resource model. A resource group is a logical container that holds related resources. When you delete a resource group, all resources inside it are deleted simultaneously.

variable "location" {
  description = "Azure region for all resources"
  type        = string
  default     = "East US"  # Azure uses human-readable region names, not codes
}

variable "environment" {
  type    = string
  default = "dev"
}

# Resource group — the first resource in every Azure Terraform configuration
resource "azurerm_resource_group" "main" {
  name     = "rg-${var.environment}-app"  # Azure convention: prefix with rg-
  location = var.location

  tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
  }

  lifecycle {
    prevent_destroy = true  # Deleting the RG deletes EVERYTHING inside it
    # This one lifecycle rule prevents the most catastrophic Azure Terraform mistake
  }
}

# Every Azure resource references the resource group explicitly
resource "azurerm_virtual_network" "main" {
  name                = "vnet-${var.environment}-app"
  resource_group_name = azurerm_resource_group.main.name      # Always reference the RG
  location            = azurerm_resource_group.main.location  # Inherit location from RG
  address_space       = ["10.0.0.0/16"]

  tags = azurerm_resource_group.main.tags  # Inherit tags from RG
}

resource "azurerm_subnet" "app" {
  name                 = "snet-${var.environment}-app"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name  # Subnet belongs to VNet
  address_prefixes     = ["10.0.1.0/24"]
}

resource "azurerm_subnet" "db" {
  name                 = "snet-${var.environment}-db"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.2.0/24"]

  # Service delegation — required for managed services that inject NICs into the subnet
  delegation {
    name = "delegation"
    service_delegation {
      name    = "Microsoft.DBforPostgreSQL/flexibleServers"
      actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
    }
  }
}

What just happened?

  • prevent_destroy on the resource group is critical. A terraform destroy that reaches the resource group — even accidentally — cascades to every resource inside it. This one lifecycle rule prevents the most catastrophic possible Terraform mistake in Azure. Set it on every resource group in a shared environment.
  • Subnet delegation is Azure-specific. Some Azure managed services (PostgreSQL Flexible Server, Azure Container Apps) inject network interfaces directly into a VNet subnet. The delegation block grants that permission. Without it, the managed service creation fails with a network permissions error — one of the most confusing errors for engineers new to Azure.

Key Azure Resource Patterns

New terms:

  • random_string resource — generates a random string for use in resource names that require global uniqueness, like storage account names. Arguments special = false and upper = false are required for storage accounts — only lowercase alphanumeric allowed.
  • data.azurerm_client_config.current — fetches the authenticated caller's tenant ID and object ID. Used when creating Key Vault access policies that grant the Terraform execution identity permission to manage the vault.
  • purge_protection_enabled — when true on an Azure Key Vault, prevents permanent deletion of the vault or its secrets even after the soft delete period. Cannot be disabled once enabled. Set it on Day 1 for production vaults.
# ── AZURE STORAGE ACCOUNT ────────────────────────────────────────────────────

# Storage account names: 3-24 chars, lowercase alphanumeric only, globally unique
# random_string generates a unique suffix that satisfies all three constraints
resource "random_string" "suffix" {
  length  = 6
  special = false  # Storage account names: no special characters
  upper   = false  # Storage account names: lowercase only
}

resource "azurerm_storage_account" "app" {
  name                = "st${var.environment}app${random_string.suffix.result}"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  account_tier        = "Standard"     # Standard (HDD) or Premium (SSD)
  account_replication_type = "LRS"     # LRS, GRS, ZRS — LRS for dev, GRS for prod

  enable_https_traffic_only       = true     # Reject HTTP, require HTTPS
  min_tls_version                 = "TLS1_2" # Require TLS 1.2 minimum
  allow_nested_items_to_be_public = false    # Block public blob access

  blob_properties {
    versioning_enabled = true  # Keep previous blob versions — for recovery
    delete_retention_policy {
      days = 7  # Soft-delete blobs for 7 days before permanent deletion
    }
  }

  tags = azurerm_resource_group.main.tags
}

# ── AZURE KEY VAULT ───────────────────────────────────────────────────────────

data "azurerm_client_config" "current" {}  # Current authenticated identity

resource "azurerm_key_vault" "app" {
  name                = "kv-${var.environment}-app"  # 3-24 chars, globally unique
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  tenant_id           = data.azurerm_client_config.current.tenant_id  # Always use current
  sku_name            = "standard"  # standard or premium (HSM-backed keys)

  soft_delete_retention_days = 7    # 7-90 day window before permanent deletion
  purge_protection_enabled   = true # Cannot be disabled once set — prod must have this

  network_acls {
    default_action = "Deny"          # Block all access by default
    bypass         = "AzureServices" # Allow trusted Azure services through
    ip_rules       = []              # Add specific IPs or subnet IDs to allowlist
  }

  tags = azurerm_resource_group.main.tags
}

# Grant Terraform execution identity permission to manage vault secrets
resource "azurerm_key_vault_access_policy" "terraform" {
  key_vault_id = azurerm_key_vault.app.id
  tenant_id    = data.azurerm_client_config.current.tenant_id
  object_id    = data.azurerm_client_config.current.object_id  # Current identity's object ID

  secret_permissions = ["Get", "Set", "Delete", "List", "Purge"]
  key_permissions    = ["Get", "Create", "Delete", "List"]
}

# ── AZURE LINUX VM — NIC must be separate resource ────────────────────────────

# NIC created separately — unlike AWS EC2 where networking is inline
resource "azurerm_network_interface" "app" {
  name                = "nic-${var.environment}-app"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.app.id   # Implicit dependency on subnet
    private_ip_address_allocation = "Dynamic"               # Azure assigns the private IP
  }
}

resource "azurerm_linux_virtual_machine" "app" {
  name                = "vm-${var.environment}-app"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  size                = "Standard_B2s"  # B-series: burstable, cost-effective for dev

  # NIC attached by ID — created as a separate resource above
  network_interface_ids = [azurerm_network_interface.app.id]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Premium_LRS"  # Premium SSD for OS disk
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts-gen2"
    version   = "latest"
  }

  admin_username                  = "azureuser"
  disable_password_authentication = true  # SSH key only — no password login

  admin_ssh_key {
    username   = "azureuser"
    public_key = file("~/.ssh/id_rsa.pub")  # Public key only — private stays local
  }

  # System-assigned managed identity — lets VM access Azure services without credentials
  identity {
    type = "SystemAssigned"
  }

  tags = azurerm_resource_group.main.tags
}

Multi-Subscription Deployments

Azure organisations use multiple subscriptions the same way AWS organisations use multiple accounts. Terraform manages this with aliased providers — one per subscription.

variable "dev_subscription_id"  { type = string }
variable "prod_subscription_id" { type = string }
variable "tenant_id"            { type = string }

# Dev subscription provider
provider "azurerm" {
  features {}
  alias           = "dev"
  subscription_id = var.dev_subscription_id
  tenant_id       = var.tenant_id
  # Credentials from ARM_ environment variables
}

# Prod subscription provider — same tenant, different subscription
provider "azurerm" {
  features {}
  alias           = "prod"
  subscription_id = var.prod_subscription_id
  tenant_id       = var.tenant_id
}

# Dev resource group in dev subscription
resource "azurerm_resource_group" "dev" {
  provider = azurerm.dev  # Use the dev provider alias
  name     = "rg-dev-app"
  location = "East US"
}

# Prod resource group in prod subscription
resource "azurerm_resource_group" "prod" {
  provider = azurerm.prod  # Use the prod provider alias
  name     = "rg-prod-app"
  location = "West Europe"
}

# Passing aliased providers to modules
module "dev_app" {
  source = "../modules/app"

  providers = {
    azurerm = azurerm.dev  # All resources in this module use the dev provider
  }

  resource_group_name = azurerm_resource_group.dev.name
  location            = azurerm_resource_group.dev.location
}

Azure RBAC with Terraform

Azure RBAC uses role assignments — granting a principal a named role at a specific scope. Unlike AWS IAM policy documents, Azure has no JSON policy syntax for custom permission sets at assignment time. You assign built-in or custom role definitions to principals at subscription, resource group, or resource scope.

# Grant the VM's managed identity read access to Key Vault
resource "azurerm_role_assignment" "vm_keyvault" {
  scope                = azurerm_key_vault.app.id    # Scope: just this Key Vault
  role_definition_name = "Key Vault Secrets User"    # Built-in role: read secrets
  principal_id         = azurerm_linux_virtual_machine.app.identity[0].principal_id
  # VM's system-assigned managed identity can now read secrets from Key Vault
}

# Grant a service principal Contributor access to the resource group
resource "azurerm_role_assignment" "cicd_contributor" {
  scope                = azurerm_resource_group.main.id
  role_definition_name = "Contributor"  # Can create/modify/delete resources in the RG
  principal_id         = var.cicd_service_principal_object_id
}

# Custom role — for Terraform execution with least privilege
resource "azurerm_role_definition" "terraform_deployer" {
  name        = "Terraform Deployer"
  scope       = "/subscriptions/${var.dev_subscription_id}"
  description = "Minimum permissions for Terraform to manage app infrastructure"

  permissions {
    actions = [
      "Microsoft.Compute/*",   # Create/manage VMs
      "Microsoft.Network/*",   # Create/manage VNets, subnets, NICs
      "Microsoft.Storage/*",   # Create/manage storage accounts
      "Microsoft.KeyVault/*",  # Create/manage Key Vaults
      "Microsoft.Resources/*", # Create/manage resource groups
    ]
    not_actions = [
      "Microsoft.Authorization/*/Delete",  # Cannot delete role assignments
      "Microsoft.Authorization/*/Write",   # Cannot create role assignments
    ]
  }

  assignable_scopes = ["/subscriptions/${var.dev_subscription_id}"]
}

Common Azure Provider Mistakes

Forgetting features {} in the provider block

The azurerm provider requires a features {} block — even when empty. Without it Terraform fails with a provider configuration error. This block configures optional provider-level behaviours including soft delete settings for Key Vaults and VM delete behaviour. Never omit it.

Not setting purge_protection_enabled on production Key Vaults

Azure Key Vault soft delete can be bypassed with a purge operation — permanently destroying secrets and keys before the retention period expires. Without purge_protection_enabled = true, an authorised identity can permanently delete encryption keys used by production databases. Once enabled, purge protection cannot be disabled — set it on Day 1.

Storage account naming violations

Azure storage account names must be 3-24 characters, globally unique across all Azure subscriptions, and contain only lowercase alphanumeric characters — no hyphens, no underscores, no uppercase. Forgetting these constraints causes cryptic validation errors. Always use the random_string resource with special = false and upper = false to generate compliant unique suffixes.

Azure naming conventions — follow the official standard

Azure has official naming convention guidance with specific resource type prefixes: rg- for resource groups, vnet- for virtual networks, snet- for subnets, vm- for virtual machines, nic- for network interfaces, kv- for Key Vaults, st for storage accounts (no hyphen — storage names cannot contain hyphens). Every resource type has a maximum length — storage accounts max at 24 characters, VMs max at 15 characters for Windows. The full reference is at learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-naming.

Practice Questions

1. Every azurerm provider block requires one block even when empty. What is it?



2. What argument must every Azure resource specify that has no equivalent in AWS resource blocks?



3. You have an aliased provider azurerm.prod. How do you pass it to a module block so all resources in that module use the prod subscription?



Quiz

1. What is an Azure resource group and why must every resource specify one?


2. How do you configure the azurerm provider for CI/CD without putting service principal credentials in the provider block?


3. How does configuring networking on an Azure VM differ from an AWS EC2 instance?


Up Next · Lesson 33

Terraform with GCP

Azure patterns learned. Lesson 33 moves to the Google Cloud provider — project-based resource scoping, service account authentication, the google and google-beta providers, GCP IAM bindings vs members, and key GCP resource patterns for Compute, Cloud Storage, and GKE.