Terraform on Azure — Resource Groups, VNets, and VMs
If you have been following this series, every example so far has used AWS. But Terraform is cloud-agnostic, and Azure is one of the most well-supported providers in the ecosystem. The AzureRM provider has 700+ resource types covering everything from VMs to Cosmos DB to AKS. This post walks you through Azure fundamentals with Terraform — authentication, resource groups, networking, and a full Linux VM deployment.
AzureRM Provider Configuration
First, tell Terraform to use the Azure provider:
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.0"
}
}
}
provider "azurerm" {
features {}
}
The empty features {} block is required — it is where you configure provider-level behavior like soft-delete protection for key vaults. Without it, the provider refuses to initialize.
Authentication Methods
Terraform needs credentials to talk to Azure. You have several options:
Azure CLI (local development):
# Log in with your browser
az login
# Terraform automatically detects CLI credentials
terraform plan
Service Principal (CI/CD and automation):
# Create a service principal
az ad sp create-for-rbac \
--name "terraform-sp" \
--role "Contributor" \
--scopes "/subscriptions/YOUR_SUBSCRIPTION_ID"
# Output:
# {
# "appId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
# "password": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
# "tenant": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# }
# Provider with service principal
provider "azurerm" {
features {}
subscription_id = var.subscription_id
client_id = var.client_id
client_secret = var.client_secret
tenant_id = var.tenant_id
}
Better yet, use environment variables so secrets never appear in code:
export ARM_SUBSCRIPTION_ID="your-subscription-id"
export ARM_CLIENT_ID="your-client-id"
export ARM_CLIENT_SECRET="your-client-secret"
export ARM_TENANT_ID="your-tenant-id"
Managed Identity (Azure VMs and App Services):
provider "azurerm" {
features {}
use_msi = true
}
This is the most secure option when running Terraform from inside Azure — no secrets to manage.
Resource Group
Every Azure resource lives inside a resource group. Think of it as a folder that groups related resources for billing, access control, and lifecycle management.
# variables.tf
variable "location" {
type = string
default = "East US"
}
variable "environment" {
type = string
default = "production"
}
variable "project" {
type = string
default = "webapp"
}
locals {
resource_prefix = "${var.project}-${var.environment}"
}
# main.tf
resource "azurerm_resource_group" "main" {
name = "${local.resource_prefix}-rg"
location = var.location
tags = {
Environment = var.environment
Project = var.project
ManagedBy = "terraform"
}
}
Virtual Network and Subnets
Azure Virtual Networks (VNets) are equivalent to AWS VPCs. Subnets in Azure are defined as child resources of the VNet.
resource "azurerm_virtual_network" "main" {
name = "${local.resource_prefix}-vnet"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
tags = azurerm_resource_group.main.tags
}
resource "azurerm_subnet" "public" {
name = "public-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_subnet" "private" {
name = "private-subnet"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.10.0/24"]
}
Network Security Group
NSGs are Azure's equivalent of AWS security groups. They contain inbound and outbound rules and can be associated with subnets or individual NICs.
resource "azurerm_network_security_group" "web" {
name = "${local.resource_prefix}-web-nsg"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
security_rule {
name = "AllowSSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "YOUR_IP/32"
destination_address_prefix = "*"
}
security_rule {
name = "AllowHTTP"
priority = 200
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "AllowHTTPS"
priority = 300
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "*"
destination_address_prefix = "*"
}
tags = azurerm_resource_group.main.tags
}
resource "azurerm_subnet_network_security_group_association" "public" {
subnet_id = azurerm_subnet.public.id
network_security_group_id = azurerm_network_security_group.web.id
}
Public IP and Network Interface
resource "azurerm_public_ip" "vm" {
name = "${local.resource_prefix}-pip"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
allocation_method = "Static"
sku = "Standard"
tags = azurerm_resource_group.main.tags
}
resource "azurerm_network_interface" "vm" {
name = "${local.resource_prefix}-nic"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.public.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.vm.id
}
tags = azurerm_resource_group.main.tags
}
Linux Virtual Machine
resource "azurerm_linux_virtual_machine" "web" {
name = "${local.resource_prefix}-vm"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
size = "Standard_B2s"
admin_username = "azureuser"
network_interface_ids = [azurerm_network_interface.vm.id]
admin_ssh_key {
username = "azureuser"
public_key = file("~/.ssh/id_rsa.pub")
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
disk_size_gb = 30
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = azurerm_resource_group.main.tags
}
Storage Account
Azure Storage Accounts are multipurpose — they hold blobs, file shares, queues, and tables. Here is a basic one:
resource "azurerm_storage_account" "main" {
name = "${var.project}${var.environment}sa"
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 = azurerm_resource_group.main.tags
}
Note: Storage account names must be globally unique, 3-24 characters, lowercase letters and numbers only. No hyphens or underscores allowed.
Outputs
output "resource_group_name" {
value = azurerm_resource_group.main.name
}
output "vm_public_ip" {
value = azurerm_public_ip.vm.ip_address
}
output "vm_private_ip" {
value = azurerm_network_interface.vm.private_ip_address
}
output "storage_account_name" {
value = azurerm_storage_account.main.name
}
output "vnet_id" {
value = azurerm_virtual_network.main.id
}
Terraform vs ARM Templates vs Bicep
| Feature | Terraform | ARM Templates | Bicep |
|---|---|---|---|
| Language | HCL | JSON | DSL (compiles to ARM) |
| Multi-cloud | Yes | Azure only | Azure only |
| State management | Explicit state file | Azure handles it | Azure handles it |
| Plan before apply | terraform plan | What-if (limited) | What-if (limited) |
| Learning curve | Moderate | Steep (verbose JSON) | Low |
| Module ecosystem | Terraform Registry | Template Specs | Module registry |
| Drift detection | terraform plan | No built-in | No built-in |
Terraform wins for multi-cloud teams and teams already using it for AWS. Bicep wins for Azure-only shops that want deep integration with Azure Resource Manager and do not want to manage state files.
Wrapping Up
The AzureRM provider follows the same patterns you learned with AWS — you declare resources, Terraform figures out the dependency graph, and apply creates everything in the right order. The key Azure concepts to remember: everything lives in a resource group, VNets are your network boundary, NSGs control traffic, and tags flow through every resource for billing and governance.
Next, we will look at Terraform import — how to bring existing infrastructure that was created manually or with other tools under Terraform's management.
