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
- Terraform CLI installed and on PATH. Verify with:
terraform --version - Azure CLI installed and logged in for local development:
az login - CI/CD or automation: prefer a Service Principal. Export these environment variables in your pipeline or local shell:
- ARM_SUBSCRIPTION_ID
- ARM_CLIENT_ID
- ARM_CLIENT_SECRET
- ARM_TENANT_ID
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:
- Use SSH keys and set disable_password_authentication = true for production.
- Store secrets in Azure Key Vault or your CI secret store; never hard‑code credentials.
- Limit public access with Network Security Groups (NSGs) and proper rules.
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:
- Save plan files to ensure the reviewed plan is what gets applied in automation.
- Terraform computes dependency order automatically; always review plans before applying or destroying.
Next Steps and Optimization
- Replace password auth with SSH key or Azure AD login.
- Move Terraform state to a remote backend (Azure Storage + blob locking) for team collaboration.
- Break the config into reusable modules (network, compute, security).
- Add tags and cost metadata for governance.
- Integrate CI/CD: use GitHub Actions or Azure Pipelines to run terraform plan on PRs and terraform apply in protected branches.
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
- Single responsibility: each module does one thing (network, compute, storage).
- Inputs and outputs: expose clear variables for configuration and concise outputs for consumption.
- Idempotence and minimal side effects: modules should create only what they declare and avoid implicit changes.
- Defaults and overrides: sensible defaults in variables, but allow overrides for environment-specific values.
- Documentation: include README with examples, expected inputs, outputs, and usage snippets.
- Versioning: publish modules in a repo with tags or use the Terraform Registry for reuse and immutability.
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:
- project_prefix, location, resource_group_name (from network module), subnet_id, ssh_public_key (recommended)
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
- Use remote state with the
backendblock anddata "terraform_remote_state"for cross-module references in separate workspaces. - Validate modules with
terraform validateand test changes in a disposable environment. - Keep secrets out of variables; reference Key Vault or CI secret stores.
- Publish modules with semantic versioning and tag releases for reproducibility.
- Add a README in each module with example usage, variable list, and outputs.
