Professional Services Infrastructure as Code

Server provisioning with Terraform & Azure

One Azure Linux VM — Ubuntu 24.04 LTS with a public IP, reachable on SSH, HTTP and HTTPS — declared entirely in version-controlled .tf files. Azure has no default network, so the configuration builds the whole stack itself: resource group, virtual network, subnet, public IP, security group and NIC. Scoped to exactly one VM, with no credentials in the code. terraform apply to create it, terraform destroy to make it disappear.

Back to Professional Services
1
VM provisioned
8
Resources created
Spot
Discounted, billable
8
Config files

What this is

This is the write-up that accompanies the studio's Azure Terraform guide — a step-by-step walkthrough of an Azure-only configuration (terraform/azure-freetier-vm) that provisions exactly one Linux virtual machine running the latest Ubuntu 24.04 LTS, with a public IP and a security group open for SSH (22), HTTP (80) and HTTPS (443). The guide explains what every file does, what every entry inside each file is for, and the exact commands used to apply it.

The headline difference from the AWS configuration: Azure has no default network. On AWS the VM drops into the account's default VPC; on Azure you must build the network yourself. So this config creates the full chain — resource group → virtual network → subnet → public IP → network security group → network interface → VM — and still keeps to a single instance with no count / for_each. It stores no credentials: Azure auth comes from your az login session and only your public SSH key is ever uploaded. The example .tf files below are the real configuration from the guide.

Terraform in 30 seconds

Terraform is infrastructure as code: you declare the resources you want in .tf files and Terraform works out the API calls to create, change, or delete them to match. The pieces you'll see below:

  • Provider — a plugin that talks to one platform's API (here, azurerm).
  • Resource — a thing Terraform creates and manages (a resource group, a vnet, the VM).
  • Variable — a typed input, so the same code is reusable per run.
  • Output — a value Terraform prints after an apply (e.g. the VM's public IP).
  • State — Terraform's record of what it created (terraform.tfstate), kept out of git because it can hold sensitive values.
  • Spot VM — discounted, evictable capacity from a separate pool; the working recipe here uses it because the free B-series sizes are capacity-restricted on this subscription.

What it provisions

Terraform apply creating one Azure VM and its full network stack Running terraform apply from your machine authenticates to Azure with your az login session. Because Azure has no default network, Terraform creates the whole stack inside a resource group: a virtual network and subnet, a static public IP, a network security group allowing inbound SSH, HTTP and HTTPS, and a network interface that ties the subnet and public IP together. It then creates a single Linux VM, running Ubuntu 24.04 LTS as a Spot instance, attached to that interface. terraform apply auth: az login session uploads public SSH key Azure · resource group (freetier-vm-rg) virtual network + subnet 10.0.0.0/16 · 10.0.1.0/24 public IP (Static) reachable from internet network security group in 22 / 80 / 443 · out all network interface (NIC) subnet + public IP + NSG azurerm_linux_virtual_machine Ubuntu 24.04 LTS · Spot · Standard_LRS disk exactly one — no count / for_each
One provider, one resource group, and the whole network chain Azure needs — vnet, subnet, public IP, NSG and NIC — wired up around a single Linux VM.

The configuration, file by file

Terraform reads all .tf files in a directory as one configuration, so the split below is purely for human organization. The directory contains:

FileWhat it's for
versions.tfTerraform + provider version pins, so the config is reproducible
variables.tfThe input variables (the knobs), each with a safe default
main.tfThe provider and every resource — the full network stack and the VM
outputs.tfValues printed after an apply (VM name, resource group, public IP, a ready-to-run SSH command)
terraform.tfvarsThe committed sample working recipe (Spot, westcentralus) — auto-loaded, contains no secrets
terraform.tfvars.exampleA fully-commented reference of every variable
.gitignoreKeeps state, plugins and local overrides out of git (but deliberately keeps terraform.tfvars)
README.mdThe module's own quick-start + troubleshooting (this page is the deeper companion)

versions.tf — version constraints

Refuse to run on a Terraform CLI older than 1.5, and pin the Azure provider to any 4.x (but not 5.0) so a surprise breaking upgrade can't sneak in.

versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

variables.tf — the inputs

Every variable has a description, a type, and a default — so the config runs with zero required input, while any value can still be overridden per run with -var or a terraform.tfvars file. The spot_* knobs turn on the discounted, evictable Spot capacity that makes this subscription's free sizes unnecessary.

variables.tf
variable "subscription_id" {
  description = "Azure subscription id to deploy into. Leave null to use the ARM_SUBSCRIPTION_ID env var or `az account show` default. Get it with: az account show --query id -o tsv"
  type        = string
  default     = null
}

variable "location" {
  description = "Azure region. B1s free-tier size is broadly available; westus2 matches the existing footprint."
  type        = string
  default     = "westus2"
}

variable "name" {
  description = "Name / prefix applied to the created resources (resource group, vnet, nic, vm, ...)."
  type        = string
  default     = "freetier-vm"
}

variable "vm_size" {
  description = "Azure VM size. Standard_B1s is the classic free-account size (1 vCPU, 1 GiB, 750 hrs/month for 12 months)."
  type        = string
  default     = "Standard_B1s"
}

variable "admin_username" {
  description = "Admin login user on the VM. Cannot be 'admin' or 'root' (Azure rejects those)."
  type        = string
  default     = "azureuser"
}

variable "public_key_path" {
  description = "Path to the SSH PUBLIC key uploaded to the VM. The matching PRIVATE key stays on your machine and is never sent to Azure or committed. Defaults to the existing RSA key."
  type        = string
  default     = "~/.ssh/id_rsa.pub"
}

variable "ssh_source_prefix" {
  description = "Source address/CIDR allowed to reach SSH (port 22). Defaults to any ('*'); tighten to your IP (e.g. \"203.0.113.4\") for better security."
  type        = string
  default     = "*"
}

variable "vnet_cidr" {
  description = "Address space for the virtual network."
  type        = string
  default     = "10.0.0.0/16"
}

variable "subnet_cidr" {
  description = "Address prefix for the subnet the VM lives in."
  type        = string
  default     = "10.0.1.0/24"
}

variable "os_disk_type" {
  description = "OS disk storage type. Standard_LRS (standard HDD) is the cheapest; Premium_LRS/StandardSSD_LRS cost more."
  type        = string
  default     = "Standard_LRS"
}

variable "spot_enabled" {
  description = "Provision the VM as an Azure Spot instance (discounted, but evictable)."
  type        = bool
  default     = false
}

variable "spot_eviction_policy" {
  description = "What happens when a Spot VM is evicted: 'Deallocate' (stop/deallocate, keep the disk) or 'Delete'."
  type        = string
  default     = "Deallocate"
}

variable "spot_max_bid_price" {
  description = "Max hourly price (USD) for the Spot VM. -1 = pay up to the on-demand price and only get evicted on capacity (never on price) — i.e. 'capacity only' eviction."
  type        = number
  default     = -1
}

main.tf — the provider and every resource

The heart of the configuration, read top to bottom: configure the azurerm provider; then build the network stack in dependency order — resource group, vnet, subnet, public IP, security group, NIC, and the NSG↔NIC association — and finally the single VM, with the optional Spot block gated on spot_enabled.

main.tf
# SCOPE: this configuration is Azure-only and provisions exactly ONE VM.
# It declares only the hashicorp/azurerm provider, and the single
# azurerm_linux_virtual_machine below uses no count/for_each. It is intentionally
# NOT a multi-cloud module and will not deploy to AWS/GCP/OCI — those clouds get
# their own separate configurations.
provider "azurerm" {
  # subscription_id falls back to the ARM_SUBSCRIPTION_ID env var / az CLI default
  # when var.subscription_id is null. Auth itself uses your `az login` session.
  subscription_id = var.subscription_id
  features {}
}

# Unlike AWS, Azure has no "default network", so we build the whole stack:
# resource group -> virtual network -> subnet -> public IP -> NSG -> NIC -> VM.

resource "azurerm_resource_group" "this" {
  name     = "${var.name}-rg"
  location = var.location
}

resource "azurerm_virtual_network" "this" {
  name                = "${var.name}-vnet"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  address_space       = [var.vnet_cidr]
}

resource "azurerm_subnet" "this" {
  name                 = "${var.name}-subnet"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = [var.subnet_cidr]
}

# Static Standard public IP so the VM is reachable from the internet.
resource "azurerm_public_ip" "this" {
  name                = "${var.name}-pip"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  allocation_method   = "Static"
  sku                 = "Standard"
}

# Network security group: inbound SSH (restrictable), HTTP and HTTPS; all
# outbound is allowed by Azure's default rules.
resource "azurerm_network_security_group" "this" {
  name                = "${var.name}-nsg"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location

  security_rule {
    name                       = "SSH"
    priority                   = 1001
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = var.ssh_source_prefix
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "HTTP"
    priority                   = 1002
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "80"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "HTTPS"
    priority                   = 1003
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "443"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

resource "azurerm_network_interface" "this" {
  name                = "${var.name}-nic"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location

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

# Attach the NSG to the NIC so its rules apply to the VM.
resource "azurerm_network_interface_security_group_association" "this" {
  network_interface_id      = azurerm_network_interface.this.id
  network_security_group_id = azurerm_network_security_group.this.id
}

# Exactly one VM: a single resource with no count/for_each. Do not add count or
# for_each here — this module is deliberately scoped to one instance.
resource "azurerm_linux_virtual_machine" "this" {
  name                  = var.name
  resource_group_name   = azurerm_resource_group.this.name
  location              = azurerm_resource_group.this.location
  size                  = var.vm_size
  admin_username        = var.admin_username
  network_interface_ids = [azurerm_network_interface.this.id]

  # Optional Azure Spot VM: discounted, evictable capacity. When spot_enabled is
  # true, max_bid_price = -1 means "capacity-only eviction" (pay up to the
  # on-demand price, never evicted on price), and eviction_policy controls what
  # happens on eviction (Deallocate = stop/deallocate, or Delete).
  priority        = var.spot_enabled ? "Spot" : "Regular"
  eviction_policy = var.spot_enabled ? var.spot_eviction_policy : null
  max_bid_price   = var.spot_enabled ? var.spot_max_bid_price : null

  # Key-only login: upload the public key; password auth stays disabled.
  admin_ssh_key {
    username   = var.admin_username
    public_key = file(pathexpand(var.public_key_path))
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = var.os_disk_type
  }

  # Latest Ubuntu 24.04 LTS (Canonical). "latest" resolves at apply time so the
  # image never goes stale.
  source_image_reference {
    publisher = "Canonical"
    offer     = "ubuntu-24_04-lts"
    sku       = "server"
    version   = "latest"
  }

  tags = {
    Name    = var.name
    Project = "multi-cloud"
  }
}

outputs.tf — what you get back

After an apply, Terraform prints these (and terraform output <name> re-prints any one). The ssh_command builds the private-key path from the public-key path and uses the admin_username.

outputs.tf
output "vm_name" {
  description = "Name of the virtual machine."
  value       = azurerm_linux_virtual_machine.this.name
}

output "resource_group" {
  description = "Resource group the VM and its networking live in."
  value       = azurerm_resource_group.this.name
}

output "public_ip" {
  description = "Public IPv4 address of the VM."
  value       = azurerm_public_ip.this.ip_address
}

output "ssh_command" {
  description = "Ready-to-run SSH command."
  value       = "ssh -i ${replace(pathexpand(var.public_key_path), ".pub", "")} ${var.admin_username}@${azurerm_public_ip.this.ip_address}"
}

terraform.tfvars & .gitignore — a committed working recipe

Unusually, terraform.tfvars is committed here (and removed from .gitignore). The module's defaults (westus2 / B1s) don't allocate on this subscription, so the file pins the recipe that does work — a Standard_B2ats_v2 Spot VM in westcentralus. Terraform auto-loads it, so apply / destroy need no -var flags. It contains no secrets.

terraform.tfvars
# Sample REAL working configuration for this module.
#
# These are the exact values confirmed to provision a running VM on the project's
# Azure subscription, where the free on-demand B-series sizes are
# capacity-restricted (see "Troubleshooting & recovery" in README.md). Unlike the
# usual convention, this terraform.tfvars is intentionally COMMITTED (and removed
# from .gitignore) so the module works out of the box with a known-good recipe.
#
# Terraform auto-loads terraform.tfvars, so `terraform apply` / `terraform destroy`
# use these values with no -var flags. For LOCAL overrides you do NOT want
# committed, put them in a *.auto.tfvars file (e.g. local.auto.tfvars) — those
# stay gitignored and take precedence over this file.
#
# NOTE: spot_enabled = true makes the default apply a BILLABLE Spot VM
# (discounted, NOT free-tier). Run `terraform destroy` when you're done.
#
# Contains no secrets (no subscription id, no keys).

location             = "westcentralus"
vm_size              = "Standard_B2ats_v2"
spot_enabled         = true
spot_eviction_policy = "Deallocate"
spot_max_bid_price   = -1
.gitignore
# Never commit Terraform state (can contain resource details / sensitive values),
# local provider plugins, crash logs, plan files, or your local variable choices.
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
crash.log
*.tfplan
# NOTE: terraform.tfvars is intentionally COMMITTED here as a sample real working
# configuration (see README "Sample working configuration"). It contains no
# secrets. Put any LOCAL overrides you don't want committed in a *.auto.tfvars
# file — those stay ignored and take precedence over terraform.tfvars.
*.auto.tfvars

No secrets in the repo. Azure credentials come from your az login session, never the code; only the public SSH key is uploaded; and state plus local override files are gitignored. Every change to the infrastructure is a reviewable diff in git.

The apply workflow

The terraform init, plan, apply, destroy workflow on Azure The workflow runs left to right: export your subscription id, terraform init downloads the azurerm provider, terraform apply with parallelism 1 creates the resource group, networking and one Spot VM and prints the SSH command, and terraform destroy with parallelism 1 tears everything back down when you are done. export ARM_…ID point at subscription terraform init download provider apply -parallelism=1 create + print outputs ssh azureuser@… from ssh_command destroy -parallelism=1 destroy when you're done — the Spot VM is billable
-parallelism=1 serializes resource creation — belt-and-suspenders against the read-after-write race a desynced clock can otherwise cause.
apply & tear down
cd terraform/azure-freetier-vm

# point Terraform at your subscription (auth itself uses your az login session)
export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"

terraform init      # downloads the azurerm provider into .terraform/
terraform plan      # preview: RG, networking, and one Spot VM to create
terraform apply -auto-approve -parallelism=1   # create (tfvars is auto-loaded)

terraform output ssh_command   # the exact command to connect

terraform destroy -auto-approve -parallelism=1 # removes everything

This one's billable. The working recipe (spot_enabled = true) creates a Spot VM — heavily discounted, but not free-tier. It can also be evicted if Azure reclaims the capacity. Always terraform destroy when you're finished.

This is the Azure sibling of the AWS configuration; the same VM exists for GCP and OCI, and the multi-cloud configuration stands up all four at once. Once a box is up, the same fleet is patched and hardened with the Ansible playbooks, and a push to main becomes a multi-OS build and release through the CI/CD pipeline.

Want your cloud infrastructure described in code, not clicked together in a console?

Reproducible, reviewable provisioning you can stand up and tear down on demand — let's talk.