What if you could build an entire Azure server environment without opening the Azure Portal and clicking your way through dozens of configuration screens? That question sits at the heart of this project. In modern cloud engineering, infrastructure is increasingly treated as code; that is to say, something that can be written, reviewed, versioned, reproduced, and deployed consistently. In this project, I used Terraform to provision a Linux Virtual Machine on Microsoft Azure, while also building the supporting network, security, public IP, and remote Terraform state infrastructure needed to manage it properly.
For a cloud engineer, this goes far beyond simply getting a virtual machine running. It is about developing the mindset and skills needed to engineer cloud infrastructure rather than manually configure it. By defining Azure resources in Terraform, managing state in Azure Storage, controlling SSH access through network security rules, and tracking the configuration with GitHub, this project demonstrates how Infrastructure as Code (IaC) brings automation, consistency, security, and repeatability into cloud operations. It also provides a practical foundation for tackling larger environments, where manually creating resources will simply never scale.
WHAT TO BUILD
- Azure Resource Group
- Azure Virtual Network
- Azure Subnet
- Azure Public IP
- Azure Network Security Group
- Azure Network Interface
- Azure Virtual Machine
- Azure Storage Account
Project Setup Instructions
⚙️ STEP 1: PREREQUISITES & AUTHENTICATION
Install Required Tools
# Install Terraform (>= 1.5.0)
# https://developer.hashicorp.com/terraform/downloads
# Install Azure CLI
# https://learn.microsoft.com/en-us/cli/azure/install-azure-cli
# Verify installations
terraform --version
az --version
Authenticate to Azure
az login
🎯 STEP 2: CONFIGURE THE REMOTE BACKEND
Create the Backend Storage Account
Run this bash script that will create a Resource group, Storage account and a Blob container.
#!/bin/bash
RESOURCE_GROUP_NAME=tfstatebackend-rg
STORAGE_ACCOUNT_NAME=azurexxxxxxxxxxxxx
CONTAINER_NAME=tfstate
# Create resource group
az group create --name $RESOURCE_GROUP_NAME --location southafricanorth
# Create storage account
az storage account create --resource-group $RESOURCE_GROUP_NAME --name $STORAGE_ACCOUNT_NAME --sku Standard_LRS --encryption-services blob
# Create blob container
az storage container create --name $CONTAINER_NAME --account-name $STORAGE_ACCOUNT_NAME
Local state does not work well in team environments; state files contain sensitive information and are at risk of accidental deletion. Azure Storage is the best approach for remote Terraform state files.
Run the Bash Script
./backend.sh
♟️ STEP 3: WRITE THE TERRAFORM CONFIGURATION FILES
3.1 backend.tf
terraform {
backend "azurerm" {
resource_group_name = "tfstatebackend-rg"
storage_account_name = "azurexxxxxxxxxxxxx"
container_name = "tfstate"
key = "terraform-azurexxxxxxxxxxxxx.tfstate"
}
}
3.2 variables.tf
variable "subscription_id" {
description = "My azure subscription ID where resources will be created"
type = string
sensitive = true
}
variable "azurerm_resource_group" {
description = "azure resource group name"
type = string
default = "september-rg"
}
variable "azurerm_location" {
description = "azure resource group location"
type = string
default = "SouthAfricaNorth"
}
variable "azurerm_virtual_network" {
description = "azure virtual network name"
type = string
default = "september-vnet"
}
variable "vnet_address_space" {
description = "Address space CIDR block for the virtual network"
type = list(string)
default = ["10.0.0.0/16"]
}
variable "tags" {
description = "Tags that apply to all resources"
type = map(string)
default = {
environment = "staging"
}
}
variable "azurerm_subnet" {
description = "azure subnet name"
type = string
default = "september-subnet"
}
variable "azurerm_subnet_address_space" {
description = "Address space CIDR block for the subnet"
type = list(string)
default = ["10.0.1.0/24"]
}
variable "azurerm_public_ip" {
description = "azure public ip name"
type = string
default = "september-public-ip"
}
variable "allocation_method" {
description = "Allocation method for the public ip address"
type = string
default = "Static"
}
variable "azurerm_network_security_group" {
description = "azure network security group name"
type = string
default = "september-nsg"
}
variable "azurerm_network_interface" {
description = "azure network interface name"
type = string
default = "september-nic"
}
variable "ip_configuration_name" {
description = "azure network interface ip configuration name"
type = string
default = "september-ip-config"
}
variable "azurerm_linux_virtual_machine" {
description = "azure linux virtual machine name"
type = string
default = "september-vm"
}
variable "vm_size" {
description = "azure linux virtual machine size"
type = string
default = "Standard_B2ats_v2"
}
variable "admin_username" {
description = "azure linux virtual machine admin username"
type = string
default = "septemberuser"
}
variable "ssh_public_key_path" {
description = "path to the SSH public key file"
type = string
default = "~/.ssh/id_rsa.pub"
}
variable "os_disk_storage_account_type" {
description = "storage account type for the OS disk"
type = string
default = "Standard_LRS"
}
variable "vm_image" {
description = "Source image configuration for the VM"
type = object({
publisher = string
offer = string
sku = string
version = string
})
default = {
publisher = "Canonical"
offer = "ubuntu-24_04-lts"
sku = "server"
version = "latest"
}
}
# Security Rules
variable "rule_direction" {
description = "Direction of the rule (Inbound or Outbound)"
type = string
default = "Inbound"
}
variable "rule_access" {
description = "Access type (Allow or Deny)"
type = string
default = "Allow"
}
variable "rule_protocol" {
description = "Protocol for the rule (Tcp, Udp, or *)"
type = string
default = "Tcp"
}
variable "source_port_range" {
description = "source port range (use * for all)"
type = string
default = "*"
}
variable "source_address_prefix" {
description = "source IP address prefix (use * for all)"
type = string
default = "*"
}
variable "destination_address_prefix" {
description = "destination IP address prefix (use '*' for all)"
type = string
default = "*"
}
# SSH Rule
variable "ssh_rule_name" {
description = "Name of the SSH inbound rule"
type = string
default = "Allow-SSH"
}
variable "ssh_prority" {
description = "priority of the SSH rule (lower is higher priority)"
type = number
default = 100
}
variable "ssh_port" {
description = "port number for SSH"
type = number
default = 22
}
variable "http_rule_name" {
description = "Name of the HTTP inbound rule"
type = string
default = "Allow-HTTP"
}
variable "http_priority" {
description = "priority of the HTTP rule (lower is higher priority)"
type = number
default = 101
}
variable "http_port" {
description = "port number for HTTP"
type = number
default = 80
}
3.3 main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "5.1.0"
}
}
}
provider "azurerm" {
# Configuration options
features {}
subscription_id = var.subscription_id
}
resource "azurerm_resource_group" "september-rg" {
name = var.azurerm_resource_group
location = var.azurerm_location
}
# Create a Virtual Network
resource "azurerm_virtual_network" "september-virtual-network" {
name = var.azurerm_virtual_network
location = azurerm_resource_group.september-rg.location
resource_group_name = azurerm_resource_group.september-rg.name
address_space = var.vnet_address_space
tags = var.tags
}
#Create a Subnet
resource "azurerm_subnet" "september-subnet" {
name = var.azurerm_subnet
resource_group_name = azurerm_resource_group.september-rg.name
virtual_network_name = azurerm_virtual_network.september-virtual-network.name
address_prefixes = var.azurerm_subnet_address_space
}
# Create a Public IP
resource "azurerm_public_ip" "september-public-ip" {
name = var.azurerm_public_ip
resource_group_name = azurerm_resource_group.september-rg.name
location = azurerm_resource_group.september-rg.location
allocation_method = var.allocation_method
tags = var.tags
}
# Create a Network Security Group
resource "azurerm_network_security_group" "september-nsg" {
name = var.azurerm_network_security_group
location = azurerm_resource_group.september-rg.location
resource_group_name = azurerm_resource_group.september-rg.name
#SSH Rule
security_rule {
name = var.ssh_rule_name
priority = var.ssh_prority
direction = var.rule_direction
access = var.rule_access
protocol = var.rule_protocol
source_port_range = var.source_port_range
destination_port_range = tostring(var.ssh_port)
source_address_prefix = var.source_address_prefix
destination_address_prefix = var.destination_address_prefix
}
#HTTP Rule
security_rule {
name = var.http_rule_name
priority = var.http_priority
direction = var.rule_direction
access = var.rule_access
protocol = var.rule_protocol
source_port_range = var.source_port_range
destination_port_range = tostring(var.http_port) # Port 80
source_address_prefix = var.source_address_prefix
destination_address_prefix = var.destination_address_prefix
}
tags = var.tags
}
# Create a Network Interface
resource "azurerm_network_interface" "september-network-interface" {
name = var.azurerm_network_interface
location = azurerm_resource_group.september-rg.location
resource_group_name = azurerm_resource_group.september-rg.name
ip_configuration {
name = var.ip_configuration_name
subnet_id = azurerm_subnet.september-subnet.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.september-public-ip.id
}
}
# Create network interface association with NSG
resource "azurerm_network_interface_security_group_association" "september-nic-nsg-association" {
network_interface_id = azurerm_network_interface.september-network-interface.id
network_security_group_id = azurerm_network_security_group.september-nsg.id
}
# Create a Virtual Machine
resource "azurerm_linux_virtual_machine" "september-vm" {
name = var.azurerm_linux_virtual_machine
resource_group_name = azurerm_resource_group.september-rg.name
location = azurerm_resource_group.september-rg.location
size = var.vm_size
admin_username = var.admin_username
network_interface_ids = [
azurerm_network_interface.september-network-interface.id
]
admin_ssh_key {
username = var.admin_username
public_key = file(var.ssh_public_key_path)
}
os_disk {
caching = "ReadWrite"
storage_account_type = var.os_disk_storage_account_type
}
source_image_reference {
publisher = var.vm_image.publisher
offer = var.vm_image.offer
sku = var.vm_image.sku
version = var.vm_image.version
}
}
output "vm_public_ip" {
description = "Public IP address of the VM"
value = azurerm_public_ip.september-public-ip.ip_address
}
🚀 STEP 4: TERRAFORM WORKFLOW
Run the terraform init command, which will Initialize the working directory, download the Azure provider, and configure the remote backend:
terraform init
4.2 terraform validate
terraform validate
4.3 terraform plan
This command previews the changes without applying them:
terraform plan
4.4 terraform apply
terraform apply
4.5 terraform state list
This command lists all resources tracked in state
terraform state list
terraform state list
Portal screen capture showing the provisioned VM is running.
Portal screen capture showing the provisioned Network Security Group and its set rules.
Portal screen capture confirming the provisioned VM Public IP.
Logged into the VM.
Every Terraform deployment teaches you something the docs don't. So here is my lesson on this one: Azure storage account names are lowercase-only, globally unique, and unforgiving. I burned a full apply cycle on it, and Terraform's error message was blunt — "can only contain lowercase letters and numbers." A small mistake, but a memorable one.
The fix was simple (drop the capitals, add a random suffix), but the lesson stuck: cloud providers enforce naming rules at the API level, not in your editor. Terraform will happily let you write invalid config — it only fails at apply time, mid-deployment.
That's the real value of this project. Not the VM itself, but the muscle memory of the workflow: remote state first, plan before apply, read the errors, fix, retry. Every mistake — the storage name, the orphaned state lock — made the next deployment smoother.
The infrastructure is live, the state is safely in Azure Blob Storage, and the next terraform apply will be clean.
I would be glad to get your views on this one.
Many thanks.
🔗 Here's my GitHub repo: https://github.com/devopsduke/DEPLOYING_AN_AZURE_LINUX_VM_WITH_TERRAFORM_AND_AZURE_REMOTE_STATE

Top comments (1)
Well done