Module 2: Provisioning Core Azure Resources With Terraform

This module teaches how to provision essential Azure resources with Terraform: resource groups, virtual networks, subnets, public IPs, network interfaces, and a Linux virtual machine with SSH access. Follow the steps to initialise your project, write clear HCL, and run the Terraform workflow (init, plan, apply, destroy).

Prerequisites and authentication

Service Principal example:

az ad sp create-for-rbac \
  --name "invurted-tf-sa" \
  --role "Contributor" \
  --scopes "/subscriptions/<SUBSCRIPTION_ID>"

Use Managed Identity for Azure-native automation when possible to avoid long-lived secrets.

Project layout and initialisation

Use a simple, maintainable directory layout for this module:

/core-azure
├─ providers.tf
├─ variables.tf
├─ main.tf
├─ outputs.tf

providers.tf

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

provider "azurerm" {
  features {}
}

Initialize the working directory once files are in place:

terraform init

Initialisation downloads providers, initialises backend and modules.

Core resources: Resource Group, VNet and Subnets

Define a Resource Group, Virtual Network and two subnets (web and internal). Terraform creates resources in the correct order based on references.

main.tf (resource group, vnet, subnets)

resource "azurerm_resource_group" "rg" {
  name     = var.resource_group_name
  location = var.location
}

resource "azurerm_virtual_network" "vnet" {
  name                = "${var.project_prefix}-vnet"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  address_space       = ["10.0.0.0/16"]

  subnet {
    name           = "${var.project_prefix}-subnet-web"
    address_prefix = "10.0.1.0/24"
  }

  subnet {
    name           = "${var.project_prefix}-subnet-internal"
    address_prefix = "10.0.2.0/24"
  }
}

variables.tf

variable "project_prefix" {
  description = "Prefix for resource names"
  type        = string
  default     = "invurted"
}

variable "resource_group_name" {
  description = "Name of the Resource Group"
  type        = string
  default     = "rg-terraform-core"
}

variable "location" {
  description = "Azure region"
  type        = string
  default     = "Australia East"
}

outputs.tf

output "resource_group_name" {
  value       = azurerm_resource_group.rg.name
  description = "Name of the created resource group"
}

output "vnet_id" {
  value       = azurerm_virtual_network.vnet.id
  description = "Virtual Network ID"
}

Networking: Public IP and Network Interface

Create a Public IP and a Network Interface attached to the web subnet. main.tf (Public IP, Data Source, NIC)

resource "azurerm_public_ip" "public_ip" {
  name                = "${var.project_prefix}-public-ip"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  allocation_method   = "Dynamic"
}

data "azurerm_subnet" "web_subnet" {
  name                 = "${var.project_prefix}-subnet-web"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
}

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

  ip_configuration {
    name                          = "internal"
    subnet_id                     = data.azurerm_subnet.web_subnet.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.public_ip.id
  }
}

Linux Virtual Machine (SSH best practices)

Prefer SSH key authentication for production. The example below demonstrates a VM using a dynamically generated password for demo purposes — replace with admin_ssh_key or Key Vault integration for production. main.tf (VM and optional provisioner)

resource "random_password" "vm_password" {
  length           = 16
  special          = true
  override_special = "_%@"
}

resource "azurerm_linux_virtual_machine" "vm" {
  name                = "${var.project_prefix}-vm"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  size                = "Standard_DS1_v2"
  admin_username      = "azureuser"
  admin_password      = random_password.vm_password.result
  disable_password_authentication = false
  network_interface_ids = [azurerm_network_interface.nic.id]

  source_image_reference {
    publisher = "Canonical"
    offer     = "UbuntuServer"
    sku       = "18.04-LTS"
    version   = "latest"
  }

  provisioner "remote-exec" {
    inline = [
      "sudo apt update",
      "sudo apt install -y nginx"
    ]

    connection {
      type     = "ssh"
      host     = self.public_ip_address
      user     = self.admin_username
      password = self.admin_password
      # For SSH key auth: replace password with private_key = file("~/.ssh/id_rsa")
    }
  }
}

Security best practices:

Terraform Workflow: Plan, Apply, Destroy

Plan — preview changes:

terraform plan
terraform plan -out="core_azure.tfplan"

Apply — execute the plan:

terraform apply "core_azure.tfplan"
# or interactively
terraform apply
# non-interactive
terraform apply -auto-approve

Destroy — tear down resources:

terraform destroy
terraform destroy -auto-approve

Notes:

Next Steps and Optimization

BONUS MATERIAL

This section explains how to break your Terraform configuration into reusable, composable modules so you can manage networking and compute independently, version and test modules, and reuse them across projects and environments.

Principles for modular design

network.tf example module

File: modules/network/main.tf

variable "project_prefix" { type = string }
variable "location"       { type = string }
variable "address_space"  { type = list(string); default = ["10.0.0.0/16"] }
variable "subnets" {
  type = list(object({ name = string, prefix = string }))
  default = [
    { name = "subnet-web",     prefix = "10.0.1.0/24" },
    { name = "subnet-internal", prefix = "10.0.2.0/24" }
  ]
}

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

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

resource "azurerm_subnet" "this" {
  for_each            = { for s in var.subnets : s.name => s }
  name                = each.value.name
  resource_group_name = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefix      = each.value.prefix
}

output "resource_group_name" { value = azurerm_resource_group.this.name }
output "vnet_id"             { value = azurerm_virtual_network.this.id }
output "subnet_ids"          { value = { for k, s in azurerm_subnet.this : k => s.id } }

Usage snippet from root module:

module "network" {
  source         = "./modules/network"
  project_prefix = var.project_prefix
  location       = var.location
}

vm.tf example module

File: modules/vm/main.tf

variable "project_prefix" { type = string }
variable "location"       { type = string }
variable "vm_size"        { type = string; default = "Standard_DS1_v2" }
variable "admin_user"     { type = string; default = "azureuser" }
variable "ssh_public_key" { type = string; default = "" } # prefer SSH key
variable "subnet_id"      { type = string }

resource "azurerm_public_ip" "this" {
  name                = "${var.project_prefix}-public-ip"
  location            = var.location
  resource_group_name = var.resource_group_name
  allocation_method   = "Dynamic"
}

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

  ip_configuration {
    name                          = "ipconfig"
    subnet_id                     = var.subnet_id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.this.id
  }
}

resource "azurerm_linux_virtual_machine" "this" {
  name                  = "${var.project_prefix}-vm"
  location              = var.location
  resource_group_name   = var.resource_group_name
  size                  = var.vm_size
  admin_username        = var.admin_user
  network_interface_ids = [azurerm_network_interface.this.id]

  dynamic "admin_ssh_key" {
    for_each = var.ssh_public_key != "" ? [1] : []
    content {
      username   = var.admin_user
      public_key = var.ssh_public_key
    }
  }

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

  source_image_reference {
    publisher = "Canonical"
    offer     = "UbuntuServer"
    sku       = "18.04-LTS"
    version   = "latest"
  }
}

output "vm_id"          { value = azurerm_linux_virtual_machine.this.id }
output "vm_public_ip"   { value = azurerm_public_ip.this.ip_address }

Required variables for VM module when called:

Usage snippet from root module:

module "vm" {
  source              = "./modules/vm"
  project_prefix      = var.project_prefix
  location            = var.location
  resource_group_name = module.network.resource_group_name
  subnet_id           = module.network.subnet_ids["subnet-web"]
  ssh_public_key      = file("~/.ssh/id_rsa.pub")
}

outputs.tf example (root module)

File: outputs.tf

output "rg_name" {
  description = "Resource Group name"
  value       = module.network.resource_group_name
}

output "vnet_id" {
  description = "Virtual Network ID"
  value       = module.network.vnet_id
}

output "subnet_ids" {
  description = "Map of subnet ids"
  value       = module.network.subnet_ids
}

output "vm_public_ip" {
  description = "Public IP address of the VM"
  value       = module.vm.vm_public_ip
}

Usage tips and best practices

comments powered by Disqus

Copyright 2025. All rights reserved.