Skip to main content

Terraform on Azure — Resource Groups, VNets, and VMs

· 5 min read
Goel Academy
DevOps & Cloud Learning Hub

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

FeatureTerraformARM TemplatesBicep
LanguageHCLJSONDSL (compiles to ARM)
Multi-cloudYesAzure onlyAzure only
State managementExplicit state fileAzure handles itAzure handles it
Plan before applyterraform planWhat-if (limited)What-if (limited)
Learning curveModerateSteep (verbose JSON)Low
Module ecosystemTerraform RegistryTemplate SpecsModule registry
Drift detectionterraform planNo built-inNo 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.