Terraform on Microsoft Azure follows the same workflow you already know from other clouds: describe the infrastructure in text files, review a plan, then apply it. This guide builds a small but complete Azure lab from an AlmaLinux 10 workstation: a resource group, a virtual network with a subnet and network security group, an AlmaLinux 10 virtual machine from the official Azure Marketplace image with a static public IP and nginx installed by cloud-init, a private storage account with blob versioning, and remote Terraform state stored in Azure Blob Storage. It is the third post in the series, after Terraform Examples for Amazon AWS from AlmaLinux 10 and Terraform Examples for Google Cloud (GCP) from AlmaLinux 10.
Versions at the time of writing: Terraform 1.17.0, the hashicorp/azurerm provider 5.x (5.10.0), Azure CLI 2.91 and AlmaLinux 10.2. Every example here passes terraform validate and terraform fmt -check. The same steps work on AlmaLinux 9.x, Rocky Linux, Oracle Linux, RHEL and CentOS Stream.
What you will build
- A resource group that holds everything, so the whole lab can be found and removed in one place
- A virtual network (
10.40.0.0/16) with one subnet (10.40.1.0/24) and a network security group allowing SSH only from your IP and HTTP from the internet - A
Standard_B2sLinux VM built fromalmalinux:almalinux-x86_64:10-gen2:latest, with Trusted Launch (Secure Boot and vTPM), SSH key login only, a static Standard SKU public IP, and nginx installed by cloud-init - A StorageV2 account with public blob access disabled, TLS 1.2 minimum, blob versioning and a private container
- Remote Terraform state in Azure Blob Storage, locked automatically with a blob lease
Prerequisites
- An AlmaLinux 10 (or 9.x) machine with sudo access and outbound HTTPS
- An Azure subscription and an account with the Contributor role on it (Owner if you also want to grant the state storage role in Example 8)
- An SSH key pair on your workstation. Azure accepts RSA and Ed25519 keys for Linux VMs. If you need one, see Configure SSH Key Authentication Only.
- A small budget. A
Standard_B2sVM and a static public IP cost a few cents an hour, so destroy the lab when you are done.
Step 1: Install Terraform from the HashiCorp repository
HashiCorp publishes an official RPM repository for RHEL-compatible systems:
sudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo dnf install -y terraform
terraform -version
Step 2: Install the Azure CLI
Microsoft ships a repository configuration package for RHEL 10, which AlmaLinux 10 uses as-is. On AlmaLinux 9, replace 10 with 9 in the URL:
sudo rpm --import https://packages.microsoft.com/keys/microsoft.asc
sudo dnf install -y https://packages.microsoft.com/config/rhel/10/packages-microsoft-prod.rpm
sudo dnf install -y azure-cli
az version
Step 3: Log in and pick a subscription
The azurerm provider can reuse your Azure CLI login, so no secret ever needs to sit in your code. On a server without a browser, add --use-device-code:
az login
az account list -o table
az account set --subscription "00000000-0000-0000-0000-000000000000"
az account show --query "{name:name, id:id, tenant:tenantId}" -o table
Check that the AlmaLinux 10 Marketplace image is offered in your region:
az vm image list --publisher almalinux --offer almalinux-x86_64 --sku 10-gen2 --all -o table
az vm image show --location centralus --urn almalinux:almalinux-x86_64:10-gen2:latest --query "{version:name, hyperV:hyperVGeneration}" -o table
For CI pipelines, use a service principal or managed identity with workload identity federation (OIDC) and the ARM_CLIENT_ID, ARM_TENANT_ID and ARM_USE_OIDC environment variables, rather than a long-lived client secret.
Step 4: Create the project layout
mkdir -p ~/terraform/azure-lab && cd ~/terraform/azure-lab
touch versions.tf variables.tf network.tf compute.tf storage.tf outputs.tf terraform.tfvars
Example 1: Pin Terraform and the providers
versions.tf pins the azurerm provider to the 5.x series. The empty features {} block is required, and since provider 4.0 subscription_id must be set explicitly (here from a variable; the ARM_SUBSCRIPTION_ID environment variable also works). The random provider generates a unique storage account name:
terraform {
required_version = ">= 1.10"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.7"
}
}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
Example 2: Input variables
variables.tf:
variable "subscription_id" {
description = "Azure subscription ID to deploy into"
type = string
}
variable "location" {
description = "Azure region for every resource"
type = string
default = "centralus"
}
variable "name" {
description = "Name prefix used for every resource"
type = string
default = "zwiegnet-lab"
}
variable "admin_cidr" {
description = "Your public IP in CIDR form, allowed to SSH to the VM"
type = string
validation {
condition = can(cidrhost(var.admin_cidr, 0))
error_message = "admin_cidr must be a valid CIDR block, for example 203.0.113.25/32."
}
}
variable "admin_username" {
description = "Login name created on the VM"
type = string
default = "azureuser"
}
variable "ssh_public_key_path" {
description = "Path to the SSH public key to install on the VM"
type = string
default = "~/.ssh/id_ed25519.pub"
}
variable "vm_size" {
description = "Azure VM size"
type = string
default = "Standard_B2s"
}
variable "tags" {
description = "Tags added to every resource"
type = map(string)
default = {
project = "zwiegnet-lab"
managed_by = "terraform"
}
}
Put your own values in terraform.tfvars:
curl -s https://checkip.amazonaws.com
cat > terraform.tfvars <<'EOF'
subscription_id = "00000000-0000-0000-0000-000000000000"
location = "centralus"
admin_cidr = "203.0.113.25/32"
EOF
terraform.tfvars holds your subscription ID and home IP, so add it to .gitignore if the project lives in a shared repository.
Example 3: Resource group, virtual network and network security group
Unlike Google Cloud, every Azure resource must live in a resource group, and the network security group (NSG) is a separate resource that you attach to a subnet or a network interface. Using standalone azurerm_network_security_rule resources keeps each rule easy to read and change. network.tf:
resource "azurerm_resource_group" "lab" {
name = "${var.name}-rg"
location = var.location
tags = var.tags
}
resource "azurerm_virtual_network" "lab" {
name = "${var.name}-vnet"
address_space = ["10.40.0.0/16"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
tags = var.tags
}
resource "azurerm_subnet" "web" {
name = "web"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.40.1.0/24"]
}
resource "azurerm_network_security_group" "web" {
name = "${var.name}-web-nsg"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
tags = var.tags
}
resource "azurerm_network_security_rule" "ssh" {
name = "allow-ssh-admin"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.admin_cidr
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.lab.name
network_security_group_name = azurerm_network_security_group.web.name
}
resource "azurerm_network_security_rule" "http" {
name = "allow-http"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "Internet"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.lab.name
network_security_group_name = azurerm_network_security_group.web.name
}
resource "azurerm_subnet_network_security_group_association" "web" {
subnet_id = azurerm_subnet.web.id
network_security_group_id = azurerm_network_security_group.web.id
}
Lower priority numbers are evaluated first. Azure’s built-in default rules deny all other inbound traffic from the internet, so only SSH from admin_cidr and HTTP reach the subnet. Internet is an Azure service tag, not a typo.
Example 4: An AlmaLinux 10 virtual machine
The AlmaLinux OS Foundation publishes official, free images on the Azure Marketplace under the publisher almalinux. The offer almalinux-x86_64 with SKU 10-gen2 is AlmaLinux 10 for Generation 2 VMs, and version = "latest" always picks the newest build. compute.tf:
resource "azurerm_public_ip" "web" {
name = "${var.name}-web-pip"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
allocation_method = "Static"
sku = "Standard"
tags = var.tags
}
resource "azurerm_network_interface" "web" {
name = "${var.name}-web-nic"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
tags = var.tags
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.web.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.web.id
}
}
resource "azurerm_linux_virtual_machine" "web" {
name = "${var.name}-web"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
size = var.vm_size
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.web.id]
tags = var.tags
disable_password_authentication = true
admin_ssh_key {
username = var.admin_username
public_key = trimspace(file(pathexpand(var.ssh_public_key_path)))
}
os_disk {
caching = "ReadWrite"
storage_account_type = "StandardSSD_LRS"
disk_size_gb = 30
}
source_image_reference {
publisher = "almalinux"
offer = "almalinux-x86_64"
sku = "10-gen2"
version = "latest"
}
secure_boot_enabled = true
vtpm_enabled = true
custom_data = base64encode(<<-EOT
#cloud-config
packages:
- nginx
runcmd:
- echo "Hello from $(hostname) on $(cat /etc/almalinux-release)" > /usr/share/nginx/html/index.html
- systemctl enable --now nginx
EOT
)
}
Points worth noting:
disable_password_authentication = truetogether withadmin_ssh_keymeans the VM only accepts key logins forazureuser.secure_boot_enabledandvtpm_enabledturn on Trusted Launch, which Gen2 AlmaLinux images support.custom_datais passed to cloud-init on first boot only. Changing it later forces Terraform to replace the VM, so treat it as bootstrap and use a configuration management tool for ongoing changes.- Standard SKU public IPs are always static and are closed to inbound traffic unless an NSG allows it, which is why the rules in Example 3 matter.
- For ARM-based sizes such as
Standard_B2ps_v2, switch the offer toalmalinux-armand pick the matching ARM SKU fromaz vm image list --publisher almalinux --offer almalinux-arm --all -o table.
Example 5: A private storage account
Storage account names must be 3 to 24 lowercase letters and numbers and unique across all of Azure, so a random suffix is added. allow_nested_items_to_be_public = false stops anyone from making a container public, and blob versioning plus a 7-day soft delete protect against accidental overwrites. storage.tf:
resource "random_string" "suffix" {
length = 6
upper = false
special = false
}
resource "azurerm_storage_account" "artifacts" {
name = "zwlab${random_string.suffix.result}"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
account_tier = "Standard"
account_replication_type = "LRS"
account_kind = "StorageV2"
min_tls_version = "TLS1_2"
tags = var.tags
allow_nested_items_to_be_public = false
blob_properties {
versioning_enabled = true
delete_retention_policy {
days = 7
}
}
}
resource "azurerm_storage_container" "artifacts" {
name = "artifacts"
storage_account_id = azurerm_storage_account.artifacts.id
container_access_type = "private"
}
In provider 4.x and later, containers reference the account with storage_account_id. Older examples that use storage_account_name on azurerm_storage_container are deprecated.
Example 6: Outputs
output "resource_group" {
value = azurerm_resource_group.lab.name
}
output "web_public_ip" {
value = azurerm_public_ip.web.ip_address
}
output "ssh_command" {
value = "ssh ${var.admin_username}@${azurerm_public_ip.web.ip_address}"
}
output "storage_account" {
value = azurerm_storage_account.artifacts.name
}
output "vnet_id" {
value = azurerm_virtual_network.lab.id
}
Step 5: Init, plan and apply
terraform init
terraform fmt -recursive
terraform validate
terraform plan -out=tfplan
terraform apply tfplan
Commit the generated .terraform.lock.hcl file so everyone uses the same provider build. A successful apply ends with output like this (names and addresses are illustrative):
Apply complete! Resources: 13 added, 0 changed, 0 destroyed.
Outputs:
resource_group = "zwiegnet-lab-rg"
ssh_command = "ssh azureuser@20.12.x.x"
storage_account = "zwlabk3x9qa"
vnet_id = "/subscriptions/0000.../resourceGroups/zwiegnet-lab-rg/providers/Microsoft.Network/virtualNetworks/zwiegnet-lab-vnet"
web_public_ip = "20.12.x.x"
Step 6: Verify the deployment
# Terraform's view
terraform output
terraform state list
# Azure's view
RG=$(terraform output -raw resource_group)
SA=$(terraform output -raw storage_account)
az vm list -g "$RG" -d --query "[].{name:name, state:powerState, ip:publicIps}" -o table
az network nsg rule list -g "$RG" --nsg-name zwiegnet-lab-web-nsg -o table
az storage account show -g "$RG" -n "$SA" --query "{publicBlobs:allowBlobPublicAccess, tls:minimumTlsVersion}" -o table
az storage account blob-service-properties show -g "$RG" --account-name "$SA" --query isVersioningEnabled
# The VM itself (give cloud-init a couple of minutes after first boot)
ssh azureuser@"$(terraform output -raw web_public_ip)" 'cat /etc/almalinux-release; sudo cloud-init status --long'
curl "http://$(terraform output -raw web_public_ip)/"
You should see the VM as VM running, your two NSG rules, false for public blob access, true for versioning, status: done from cloud-init, and a Hello from ... page from nginx. Run terraform plan once more; No changes means the code and Azure agree.
Example 7: Remote state in Azure Blob Storage
The azurerm backend stores state as a blob and takes a blob lease while a plan or apply runs, so locking works without any extra table or service. Keep the state storage outside the lab resource group so terraform destroy never touches it:
STATE_RG="tfstate-rg"
STATE_SA="zwtfstate$RANDOM"
az group create -n "$STATE_RG" -l centralus
az storage account create -n "$STATE_SA" -g "$STATE_RG" -l centralus \
--sku Standard_LRS --kind StorageV2 --min-tls-version TLS1_2 \
--allow-blob-public-access false
az storage account blob-service-properties update -g "$STATE_RG" \
--account-name "$STATE_SA" --enable-versioning true
az storage container create -n tfstate --account-name "$STATE_SA" --auth-mode login
echo "$STATE_SA"
With use_azuread_auth = true, Terraform reaches the blob with your Entra ID login instead of a storage account key. That needs a data-plane role, which Contributor alone does not include:
az role assignment create --role "Storage Blob Data Contributor" \
--assignee "$(az ad signed-in-user show --query id -o tsv)" \
--scope "$(az storage account show -n "$STATE_SA" -g "$STATE_RG" --query id -o tsv)"
Add backend.tf. Backend blocks cannot use variables, so write the names in directly:
terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "zwtfstate12345"
container_name = "tfstate"
key = "lab.terraform.tfstate"
use_azuread_auth = true
}
}
Move the local state into the container and check it arrived (role assignments can take a few minutes to apply):
terraform init -migrate-state
az storage blob list --account-name "$STATE_SA" -c tfstate --auth-mode login --query "[].name" -o tsv
Step 7: Destroy the lab
terraform destroy
Type yes to confirm. Deleting the storage account removes its blobs too, so copy anything you want to keep first. The state account in tfstate-rg is not managed by this configuration and stays in place for the next run.
Troubleshooting
subscription ID could not be determined: setsubscription_idinterraform.tfvarsor exportARM_SUBSCRIPTION_ID. Provider 4.0 and later no longer guess it.MissingSubscriptionRegistration: the subscription has not registered a resource provider yet. Runaz provider register --namespace Microsoft.Compute(andMicrosoft.Network,Microsoft.Storage) and wait untilaz provider show -n Microsoft.Compute --query registrationStatesaysRegistered.MarketplacePurchaseEligibilityFailedor a legal terms error: check the image withaz vm image terms show --urn almalinux:almalinux-x86_64:10-gen2:latestand, if it asks, accept once per subscription withaz vm image terms accept --urn almalinux:almalinux-x86_64:10-gen2:latest.SkuNotAvailableorQuotaExceeded: the VM size is not offered or out of quota in that region. List options withaz vm list-skus -l centralus --size Standard_B -o table, or changevm_sizeorlocation.StorageAccountAlreadyTaken: storage names are global. Runterraform apply -replace=random_string.suffixto get a new suffix.- SSH times out: confirm
admin_cidrstill matches your public IP and the NSG association exists withaz network vnet subnet show -g zwiegnet-lab-rg --vnet-name zwiegnet-lab-vnet -n web --query networkSecurityGroup.id. - nginx does not answer: read the cloud-init log with
sudo less /var/log/cloud-init-output.logon the VM, or view the boot log in the portal under Boot diagnostics. If you enabled firewalld on the VM yourself, also runsudo firewall-cmd --add-service=http --permanent && sudo firewall-cmd --reload. AuthorizationPermissionMismatchduring backend init: the Storage Blob Data Contributor role has not propagated yet, or was assigned on the wrong scope. Wait a few minutes and runterraform initagain.Error acquiring the state lock: another run holds the blob lease. When you are sure nothing else is running, useterraform force-unlock LOCK_ID.
Next steps
From here, move the VM into a Virtual Machine Scale Set behind an Azure Load Balancer, split the network into a reusable module, and run terraform plan in CI on every pull request with OIDC authentication. The final post in this series builds the same lab on Oracle Cloud Infrastructure. For the other clouds, see Terraform Examples for Amazon AWS from AlmaLinux 10 and Terraform Examples for Google Cloud (GCP) from AlmaLinux 10.
