azurerm is one of the most mature community-maintained Terraform providers that exists, backed directly by Microsoft, and it's usually the first place I reach for provisioning Azure infrastructure over Bicep or ARM templates — mainly because it lets the same team manage multi-cloud infrastructure without switching tools per provider.

Provider and remote state setup

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.90"
    }
  }
  backend "azurerm" {
    resource_group_name  = "tfstate-rg"
    storage_account_name = "tfstateacct"
    container_name       = "tfstate"
    key                  = "prod/network.tfstate"
  }
}

provider "azurerm" {
  features {}
  # Auth via environment variables (ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID,
  # ARM_SUBSCRIPTION_ID) or better — OIDC federation from CI, no stored secret at all.
}

The state backend uses Azure Storage with blob leasing for state locking automatically — no separate lock table needed, unlike some other providers' backend setups. Enable soft delete and versioning on that storage account; state file loss is one of the more painful ways to lose a day.

A minimal VNet + VM + Load Balancer module

resource "azurerm_resource_group" "main" {
  name     = "app-rg"
  location = "Southeast Asia"
}

resource "azurerm_virtual_network" "main" {
  name                = "app-vnet"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
}

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

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

  security_rule {
    name                       = "allow-lb-probe"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "8080"
    source_address_prefix      = "AzureLoadBalancer"
    destination_address_prefix = "*"
  }
}

resource "azurerm_linux_virtual_machine" "app" {
  count                 = 2
  name                  = "app-vm-${count.index}"
  resource_group_name   = azurerm_resource_group.main.name
  location              = azurerm_resource_group.main.location
  size                  = "Standard_D2s_v5"
  admin_username        = "azureuser"
  network_interface_ids = [azurerm_network_interface.app[count.index].id]

  admin_ssh_key {
    username   = "azureuser"
    public_key = file("~/.ssh/id_rsa.pub")
  }

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

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

  identity {
    type = "SystemAssigned"
  }
}

resource "azurerm_lb" "public" {
  name                = "app-lb"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  sku                 = "Standard"

  frontend_ip_configuration {
    name                 = "public-frontend"
    public_ip_address_id = azurerm_public_ip.lb.id
  }
}

Where azurerm diverges from AWS/GCP habits

The provider is excellent, but a few patterns cost real time the first time through:

  • Resource groups are a first-class concept with no direct equivalent elsewhere. Every resource belongs to exactly one resource group, and destroying a resource group cascades to everything inside it. This is powerful for environment teardown, but it also means a stray terraform destroy targeting the wrong resource group is more catastrophic than the AWS/GCP equivalent.
  • NSGs can attach to both subnets and individual NICs. Decide on one layer per environment and stick to it — mixing subnet-level and NIC-level rules across a team is how nobody can explain why traffic is blocked six weeks later.
  • azurerm resource naming often doesn't match the portal's display names. azurerm_linux_virtual_machine is what the portal just calls "Virtual machine" — keep the provider docs open for the first module.

Authenticating CI without stored secrets

The cleanest pattern for GitHub Actions is OIDC federation — no ARM_CLIENT_SECRET sitting in repository secrets at all:

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 }}

  - name: Terraform Plan
    run: terraform plan -out=tfplan

Set up the federated credential once on the Azure AD App Registration, scoped to the specific GitHub repo and branch — this closes off the entire class of incidents where a leaked long-lived secret in CI becomes a full subscription compromise.

Running this alongside other clouds

If this module lives in a repository next to AWS or Alibaba Cloud infrastructure (see my Terraform on Alibaba Cloud piece), provider aliasing and a consistent module interface — inputs like cidr_block, instance_count, instance_size normalized across providers — keeps a multi-cloud repository readable instead of turning into a maze of provider-specific conditionals.