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 destroytargeting 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.
azurermresource naming often doesn't match the portal's display names.azurerm_linux_virtual_machineis 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.