DEV Community

Cover image for DEPLOYING AN AZURE LINUX VM WITH TERRAFORM AND AZURE REMOTE STATE
daniel ugot
daniel ugot

Posted on

DEPLOYING AN AZURE LINUX VM WITH TERRAFORM AND AZURE REMOTE STATE

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

  1. Azure Resource Group
  2. Azure Virtual Network
  3. Azure Subnet
  4. Azure Public IP
  5. Azure Network Security Group
  6. Azure Network Interface
  7. Azure Virtual Machine
  8. 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

Enter fullscreen mode Exit fullscreen mode

Authenticate to Azure

az login
Enter fullscreen mode Exit fullscreen mode

🎯 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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode




♟️ 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"
  }
}

Enter fullscreen mode Exit fullscreen mode

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
}



Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

🚀 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
Enter fullscreen mode Exit fullscreen mode


4.2 terraform validate

terraform validate
Enter fullscreen mode Exit fullscreen mode


4.3 terraform plan

This command previews the changes without applying them:

terraform plan
Enter fullscreen mode Exit fullscreen mode


4.4 terraform apply

terraform apply
Enter fullscreen mode Exit fullscreen mode


4.5 terraform state list

This command lists all resources tracked in state
terraform state list

terraform state list
Enter fullscreen mode Exit fullscreen mode



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)

Collapse
 
prayo25 profile image
Ezekiel Olaniyan

Well done