Terraform Examples for Microsoft Azure from AlmaLinux 10

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_B2s Linux VM built from almalinux: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_B2s VM 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 = true together with admin_ssh_key means the VM only accepts key logins for azureuser.
  • secure_boot_enabled and vtpm_enabled turn on Trusted Launch, which Gen2 AlmaLinux images support.
  • custom_data is 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 to almalinux-arm and pick the matching ARM SKU from az 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: set subscription_id in terraform.tfvars or export ARM_SUBSCRIPTION_ID. Provider 4.0 and later no longer guess it.
  • MissingSubscriptionRegistration: the subscription has not registered a resource provider yet. Run az provider register --namespace Microsoft.Compute (and Microsoft.Network, Microsoft.Storage) and wait until az provider show -n Microsoft.Compute --query registrationState says Registered.
  • MarketplacePurchaseEligibilityFailed or a legal terms error: check the image with az vm image terms show --urn almalinux:almalinux-x86_64:10-gen2:latest and, if it asks, accept once per subscription with az vm image terms accept --urn almalinux:almalinux-x86_64:10-gen2:latest.
  • SkuNotAvailable or QuotaExceeded: the VM size is not offered or out of quota in that region. List options with az vm list-skus -l centralus --size Standard_B -o table, or change vm_size or location.
  • StorageAccountAlreadyTaken: storage names are global. Run terraform apply -replace=random_string.suffix to get a new suffix.
  • SSH times out: confirm admin_cidr still matches your public IP and the NSG association exists with az 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.log on the VM, or view the boot log in the portal under Boot diagnostics. If you enabled firewalld on the VM yourself, also run sudo firewall-cmd --add-service=http --permanent && sudo firewall-cmd --reload.
  • AuthorizationPermissionMismatch during backend init: the Storage Blob Data Contributor role has not propagated yet, or was assigned on the wrong scope. Wait a few minutes and run terraform init again.
  • Error acquiring the state lock: another run holds the blob lease. When you are sure nothing else is running, use terraform 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.

Leave a Reply

Your email address will not be published. Required fields are marked *