azureterraformlinuxinfrastructure-as-codedevopsvirtual-machines

Provisioning a Linux VM on Azure with Terraform

May 2, 2026·12 min read

A hands-on walkthrough of provisioning a production-ready Azure Linux VM using Terraform — covering every component from networking to SSH authentication and remote state management.

Terraform Azure Linux VM

This project uses Terraform to provision a Linux Virtual Machine on Microsoft Azure — entirely through code. Instead of manually clicking through the Azure Portal to create a network, an IP address, and a VM, you write .tf files that describe what you want, and Terraform builds it automatically.

The goal is simple: describe infrastructure as code, run one command, get a running VM.


Architecture Overview

Terraform Azure Linux VM — Architecture Diagram Full provisioning flow — from Terraform CLI to a running Azure Linux VM


The Big Picture — How Everything Connects

There are two worlds in this diagram:

  • Outside Azure — where Terraform runs on your local machine
  • Inside Azure — where your actual infrastructure lives

And one bridge between them: Azure Resource Manager.

Your Machine                    Microsoft Azure
─────────────────               ──────────────────────────────────
Terraform CLI        ──────►    Azure Resource Manager
  init/plan/apply               (authenticates + provisions)
                                        │
                                        ▼
                                  Resource Group
                                        │
                                   Virtual Network
                                        │
                                      Subnet
                                     ┌──┴──┐
                                  Public IP  NIC
                                        └──┬──┘
                                         Linux VM

There is also a feedback loop — Azure Blob Storage stores the Terraform state file and feeds it back to the Terraform CLI on every run so it knows what already exists.


Every Component Explained

Terraform CLI — init / plan / apply

This is the tool running on your local machine. It is the starting point of everything. Three commands drive the entire lifecycle:

CommandWhat it does
terraform initDownloads the Azure provider plugin, connects to the remote state backend
terraform planReads current state, calls Azure API read-only, shows exactly what will change
terraform applyExecutes the plan — creates, modifies, or destroys real Azure resources

Nothing in Azure is touched until you run apply and confirm. Terraform is declarative — you describe the desired end state, not a sequence of steps.


Azure Resource Manager (ARM)

ARM is Microsoft Azure's central control plane. Every single Azure resource — whether created via Portal, CLI, SDK, or Terraform — goes through ARM.

When Terraform runs apply, it does not directly create a VM or a network. It sends API calls to ARM, which then authenticates the request and provisions the resources on your behalf.

ARM is responsible for:

  • Verifying your identity and subscription
  • Enforcing Azure policies and quotas
  • Routing creation requests to the correct Azure service
  • Returning resource IDs back to Terraform for the state file

Think of ARM as the gatekeeper and orchestrator of everything inside Azure.


Resource Group — HRK-resources · West US

A Resource Group is Azure's logical container for related resources. Every Azure resource must belong to exactly one Resource Group.

In this project, the Resource Group does three important things:

Scopes the regionWest US is defined once on the Resource Group, and every resource inside inherits it. You never repeat the location.

Defines the lifecycle boundary — deleting the Resource Group deletes everything inside it cleanly in one operation. This is how terraform destroy wipes the entire stack.

Organises billing and access — you can assign cost tracking, permissions, and monitoring policies at the Resource Group level, which automatically apply to everything inside.

It is the first resource Terraform creates, because everything else depends on it.


Virtual Network — HRK-network · 10.0.0.0/16

The VNet is Azure's equivalent of a private data centre network. It is a completely isolated network that only your resources can see — nothing from the public internet can reach inside it by default.

The address space 10.0.0.0/16 defines the total pool of private IP addresses available inside this network — 65,536 addresses. This is a standard RFC 1918 private range.

The VNet is the foundation layer for all networking in this project. The subnet, NIC, and VM are all built on top of it.


Subnet — Internal · 10.0.2.0/24

A subnet is a logical subdivision carved out of the VNet. While the VNet defines the entire address space, the subnet is the actual segment where resources get placed.

10.0.2.0/24 gives 256 addresses within the parent VNet's space. Network Interfaces are always attached to a subnet — not directly to the VNet.

Why subnets matter beyond just dividing IPs:

  • Each subnet can have its own Network Security Group (firewall rules)
  • Each subnet can have its own route table (traffic control)
  • You can isolate different tiers — web, app, database — into separate subnets with different access rules

Public IP — Static · Standard SKU

The Public IP is an independent Azure resource that gives the VM a reachable address on the public internet. It is not part of the NIC or the VM — it exists on its own and gets associated to the NIC.

Static allocation — the IP address is assigned permanently and does not change across VM reboots or redeployments. A dynamic IP would change every time the VM is deallocated, breaking SSH configs and any DNS records pointing at it.

Standard SKU — the modern tier for Public IPs. It is secure by default — all inbound traffic is blocked unless you explicitly allow it through an NSG. It is also required for availability zones and Standard Load Balancers. The older Basic SKU is deprecated.


Network Interface — HRK-nic · Dynamic private IP

The NIC is the virtual network adapter that attaches to the VM. Every VM must have at least one NIC, and the NIC is what actually connects the VM to the network.

The NIC acts as the binding point for three things:

Subnet    ──┐
             ├──►  NIC  ──►  VM
Public IP  ──┘
  • Subnet — determines which network segment the VM lives in
  • Private IP — allocated dynamically from the subnet pool, stays stable for the lifetime of the NIC
  • Public IP — associated here, giving the VM internet reachability

The NIC is created after both the Subnet and the Public IP exist, because it references both.


Linux VM — Ubuntu 22.04 LTS · Standard_D2s_v3 · SSH key auth

The VM is the final resource in the dependency chain — it can only be created after the NIC exists. It is the actual compute instance running your workload.

Standard_D2s_v3 — 2 vCPUs, 8 GB RAM. The D series is Azure's general-purpose tier. The s means premium storage support. This size is the standard starting point for dev and staging workloads.

Ubuntu 22.04 LTS — the operating system image published by Canonical. LTS means Long Term Support with security patches until 2027. It is identified in Terraform by four fields: publisher, offer, SKU, and version — the coordinates of Azure's image marketplace.

SSH key authentication — the VM is provisioned with your public key baked in. No password is set. To connect you use your private key from your local machine:

ssh -i ~/.ssh/id_rsa testadmin@<public-ip>

This is the correct security posture — passwords on cloud VMs are a common attack vector.


Azure Blob Storage — Remote State · terraform.tfstate

Azure Blob Storage — Terraform Remote State Backend terraform.tfstate stored in the backend container inside Azure Blob Storage

This sits outside the main provisioning chain but is arguably the most important operational component.

Terraform's state file (terraform.tfstate) is its memory — a JSON record of every resource it has created, mapping your .tf code to real Azure resource IDs. Without it, Terraform has no idea what exists in Azure.

By storing it in Azure Blob Storage:

Problem with local stateHow Blob Storage solves it
State lives on one person's laptopAnyone on the team can run plan or apply
Two people running apply simultaneously corrupts stateBlob leases act as a lock — only one apply runs at a time
Accidental deletion loses all stateBlob versioning lets you recover previous state snapshots
State contains sensitive resource IDsBlob Storage encryption protects it at rest

The dashed arrow from Blob Storage back to Terraform CLI in the diagram represents this feedback — on every plan or apply, Terraform first reads the current state from Blob Storage to know what already exists before deciding what to do next.


The Dependency Chain — Why Order Matters

Terraform doesn't create resources randomly. It reads the references between them and builds a dependency graph:

Resource Group
    │
    ├──► Virtual Network
    │         │
    │         └──► Subnet ──────────────────┐
    │                                       │
    └──► Public IP ─────────────────────────┤
                                            │
                                    Network Interface
                                            │
                                        Linux VM

Resources with no dependency on each other — like Subnet and Public IP — are created in parallel, which is why Terraform is faster than a sequential script. Resources that depend on others wait until their dependencies are ready.

This graph also determines destroy order — Terraform tears down in reverse, deleting the VM before the NIC, the NIC before the subnet, and so on.


Component Summary

ComponentRole
Terraform CLITranslates your code into Azure API calls
Azure Resource ManagerAuthenticates and executes every provisioning request
Resource GroupLogical boundary that contains and scopes all resources
Virtual NetworkIsolated private network — the foundation of all connectivity
SubnetThe segment within the VNet where the VM actually lives
Public IPFixed internet-facing address, independent of the VM
Network InterfaceBinds subnet + public IP together and attaches to the VM
Linux VMThe compute instance — the end goal of the entire chain
Azure Blob StorageStores Terraform state so the team shares one source of truth

Implementation

Provider and Backend Configuration

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "=4.1.0"
    }
  }
}

provider "azurerm" {
  features {}
  subscription_id = "YOUR-SUBSCRIPTION-ID"
}

Why pin the provider version? The version = "=4.1.0" constraint locks the AzureRM provider to an exact release. Without this, a future terraform init could pull a newer major version with breaking changes and silently break your configuration.

Why features {}? The block is required by the AzureRM provider even when empty. It controls optional soft-delete and purge behaviours for services like Key Vault. Leaving it empty uses the provider defaults.


backend.tf — Remote State Configuration

terraform {
  backend "azurerm" {
    storage_account_name = "azurebackendstoragehari"
    container_name       = "backend"
    key                  = "terraform.tfstate"
    # access_key is read from ARM_ACCESS_KEY env variable — never hardcode it
  }
}

Security: Never paste the access key directly into backend.tf. Set it as an environment variable instead:

export ARM_ACCESS_KEY="$(az storage account keys list \
  --account-name azurebackendstoragehari \
  --query '[0].value' -o tsv)"

variables.tf

variable "prefix" {
  description = "Prefix applied to all resource names"
  default     = "HRK"
}

variable "ssh_public_key_path" {
  description = "Path to your local SSH public key"
  default     = "~/.ssh/id_rsa.pub"
}

main.tf — All Resources

# Resource Group
resource "azurerm_resource_group" "example" {
  name     = "${var.prefix}-resources"
  location = "West US"
}

# Virtual Network
resource "azurerm_virtual_network" "main" {
  name                = "${var.prefix}-network"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name
}

# Subnet
resource "azurerm_subnet" "internal" {
  name                 = "internal"
  resource_group_name  = azurerm_resource_group.example.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.2.0/24"]
}

# Public IP
resource "azurerm_public_ip" "main" {
  name                = "${var.prefix}-public-ip"
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

# Network Interface
resource "azurerm_network_interface" "main" {
  name                = "${var.prefix}-nic"
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.internal.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.main.id
  }
}

# Linux Virtual Machine
resource "azurerm_linux_virtual_machine" "main" {
  name                = "${var.prefix}-vm"
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name
  size                = "Standard_D2s_v3"
  admin_username      = "testadmin"

  network_interface_ids = [
    azurerm_network_interface.main.id
  ]

  admin_ssh_key {
    username   = "testadmin"
    public_key = file(var.ssh_public_key_path)
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts"
    version   = "latest"
  }

  tags = {
    environment = "staging"
  }
}

outputs.tf

output "public_ip_address" {
  description = "The public IP address of the VM"
  value       = azurerm_public_ip.main.ip_address
}

output "ssh_command" {
  description = "Ready-to-use SSH command"
  value       = "ssh -i ~/.ssh/id_rsa testadmin@${azurerm_public_ip.main.ip_address}"
}

Full Provisioning Flow

terraform init
  └─ Downloads azurerm provider v4.1.0
  └─ Connects to Azure Blob Storage backend

terraform plan
  └─ Reads current state from Blob Storage
  └─ Calls Azure Resource Manager API (read-only)
  └─ Computes diff: what to create / change / destroy

terraform apply
  └─ ARM authenticates via subscription + CLI credentials
  └─ Creates: Resource Group
  └─ Creates: Virtual Network  (depends on RG)
  └─ Creates: Subnet           (depends on VNet)
  └─ Creates: Public IP        (depends on RG — parallel with Subnet)
  └─ Creates: NIC              (depends on Subnet + Public IP)
  └─ Creates: Linux VM         (depends on NIC)
  └─ Writes updated state → Azure Blob Storage

Key Takeaways

  • Terraform models Azure's dependency graph — resources are created in the correct order automatically based on references between them.
  • The NIC is the network attachment point — it binds the subnet placement and the public IP into one object that the VM references.
  • Public IP is an independent resource in Azure — it is associated to the NIC, not owned by it. This lets you detach and re-attach IPs without recreating the VM.
  • Remote state in Blob Storage is not optional for teams — it provides locking, shared access, and a reliable source of truth for your infrastructure.
  • Always add an NSG — a VM with a Public IP and no NSG is accessible from the entire internet on all ports.

Source Code

The full Terraform configuration for this deployment is available in the DevOps Portfolio repository.

terraform-azure-vm/
  main.tf        ← all resources defined above
  variables.tf   ← prefix and ssh_public_key_path
  outputs.tf     ← public_ip_address output
  backend.tf     ← Azure Blob Storage remote state