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.
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
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:
| File | What it's for |
|---|---|
versions.tf | Terraform + provider version pins, so the config is reproducible |
variables.tf | The input variables (the knobs), each with a safe default |
main.tf | The provider and every resource — the full network stack and the VM |
outputs.tf | Values printed after an apply (VM name, resource group, public IP, a ready-to-run SSH command) |
terraform.tfvars | The committed sample working recipe (Spot, westcentralus) — auto-loaded, contains no secrets |
terraform.tfvars.example | A fully-commented reference of every variable |
.gitignore | Keeps state, plugins and local overrides out of git (but deliberately keeps terraform.tfvars) |
README.md | The 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.
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.
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.
# 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.
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.
# 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
# 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
-parallelism=1 serializes resource creation — belt-and-suspenders against the read-after-write race a desynced clock can otherwise cause.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.