DEV Community

Cover image for Building And Securing An Azure Virtual Machine With Terraform, From Zero To A Working Website
Ipadeola Taiwo
Ipadeola Taiwo

Posted on

Building And Securing An Azure Virtual Machine With Terraform, From Zero To A Working Website

This is a full walkthrough of a real Terraform project, provisioning a Linux virtual machine on Azure, securing it properly, and deploying a working web server on it. Every command in this post is one I actually ran, every error is one I actually hit, and every fix is exactly how I solved it. My goal here is not just to show you a finished project, my goal is to teach you how to build this yourself, line by line, understanding why each piece exists and what happens when something goes wrong.

If you follow this post from top to bottom, you will end with your own Linux server running on Azure, provisioned entirely as code, reachable over SSH, and serving a real webpage to anyone who visits its public address.

What We Are Building

A single Ubuntu virtual machine, sitting inside its own virtual network, protected by a firewall that only allows the two ports it actually needs, SSH for management and HTTP for web traffic, with a static public IP address so that address never changes once assigned.

Prerequisites

Terraform installed on your machine, version one point zero or higher. The Azure CLI installed and signed in with az login. An active Azure subscription. And an SSH key pair already generated on your machine, most systems already have one sitting at ~/.ssh/id_rsa.pub, if not, generate one with ssh-keygen before continuing.

Project Structure

Four files make up this entire project.

providers.tf
variables.tf
main.tf
outputs.tf
Enter fullscreen mode Exit fullscreen mode

Terraform does not care how you split your code across files. Every file in the same folder is automatically treated as one combined configuration. Splitting things apart like this is purely for readability, so let's go through each file and understand what it is doing.

providers.tf, Telling Terraform Which Cloud We Are Talking To

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "4.80.0"
    }
  }
}

provider "azurerm" {
  features {}
}
Enter fullscreen mode Exit fullscreen mode

This file has one job, telling Terraform that this project talks to Azure, using a specific, pinned version of the provider. Pinning the version matters more than it might look like at first, if you leave this open ended, your project could quietly behave differently every time you run it, depending on whatever the latest provider release happens to be that day. Pinning it to 4.80.0 means this project behaves exactly the same way every single time, on any machine, forever.

variables.tf, The Values That Drive Everything Else

variable "project" {
  description = "project name"
  type        = string
  default     = "terraform_vm"
}

variable "location" {
  description = "azure region"
  type        = string
  default     = "East US"
}

variable "owner" {
  description = "owner tag"
  type        = string
  default     = "Hagital"
}

variable "vm-size" {
  description = "vm size"
  type        = string
  default     = "Standard_B1ls"
}
Enter fullscreen mode Exit fullscreen mode

Four variables, the project name, the Azure region, an owner tag for organizing resources, and the size of the virtual machine. Every resource in main.tf pulls its values from here rather than having anything typed directly into it, so changing the region or the VM size later means editing one line here, not hunting through every resource individually.

One thing worth explaining clearly, since it genuinely confused me the first time I wrote a Terraform project. If you come from a general programming background, you are used to variables needing to be exported from one file and imported into another. Terraform does not work that way at all. There is no export keyword, no import statement, nothing to wire together. Once a variable is declared anywhere in this folder, it is available anywhere else in this same folder, simply by writing var.project, or var.location, or whichever name you gave it. The folder itself is the boundary, not the individual file.

main.tf, The Actual Infrastructure

This is the heart of the project. Nine resources, built up in the order they logically depend on each other. Let's go through each one.

The Resource Group

resource "azurerm_resource_group" "hagital-rg" {
  name     = "${var.project}_hagital_rg"
  location = var.location

  tags = {
    Environment = "Hagital-vm"
    Owner       = var.owner
    Project     = var.project
  }
}
Enter fullscreen mode Exit fullscreen mode

Every resource in Azure needs a container to live inside, called a resource group. This is the first thing created, and every other resource in this file references it, either directly or indirectly.

The Virtual Network And Subnet

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

  tags = {
    Environment = "Hagital_vnet"
    Owner       = var.owner
    Project     = var.project
  }
}

resource "azurerm_subnet" "hagital-subnet" {
  name                 = "${var.project}_hagital_subnet"
  resource_group_name  = azurerm_resource_group.hagital-rg.name
  virtual_network_name = azurerm_virtual_network.hagital-vnet.name
  address_prefixes     = ["10.0.1.0/24"]
}
Enter fullscreen mode Exit fullscreen mode

A VM needs a network to sit inside before it can exist. The virtual network defines the overall address space, and the subnet carves out a smaller section of it for this specific machine to live in. Notice how resource_group_name here is not typed as a plain string, it directly references azurerm_resource_group.hagital-rg.name. This is how Terraform understands dependency order automatically, it sees that the subnet needs the resource group to exist first, and builds everything in the correct sequence without you having to say so explicitly.

The Firewall, And Its Rules

resource "azurerm_network_security_group" "NSG" {
  name                = "${var.project}_hagital_NSG"
  location            = azurerm_resource_group.hagital-rg.location
  resource_group_name = azurerm_resource_group.hagital-rg.name
}

resource "azurerm_network_security_rule" "NSG_rule_port_80" {
  name                        = "${var.project}nsg_port_80"
  priority                    = 100
  direction                   = "Inbound"
  access                      = "Allow"
  protocol                    = "Tcp"
  source_port_range           = "*"
  destination_port_range      = "80"
  source_address_prefix       = "*"
  destination_address_prefix  = "*"
  resource_group_name         = azurerm_resource_group.hagital-rg.name
  network_security_group_name = azurerm_network_security_group.NSG.name
}

resource "azurerm_network_security_rule" "NSG_rule_port_22" {
  name                        = "${var.project}nsg_port-22"
  priority                    = 101
  direction                   = "Inbound"
  access                      = "Allow"
  protocol                    = "Tcp"
  source_port_range           = "*"
  destination_port_range      = "22"
  source_address_prefix       = "*"
  destination_address_prefix  = "*"
  resource_group_name         = azurerm_resource_group.hagital-rg.name
  network_security_group_name = azurerm_network_security_group.NSG.name
}
Enter fullscreen mode Exit fullscreen mode

A network security group, NSG for short, is Azure's firewall. On its own it does nothing, it is just a container for rules. Each rule below it defines exactly one allowed pattern of traffic.

Both rules here are inbound, meaning traffic coming into the VM from outside, not traffic leaving it. Port eighty allows web visitors to reach the site once it is running. Port twenty two allows SSH connections in, so you can actually manage the server. Notice each rule has its own priority number, one hundred and one hundred one. Two inbound rules in Azure can never share the same priority, each one needs a unique number, and spacing them out, rather than using consecutive numbers, leaves room to insert new rules later without renumbering everything.

Notice too, source_port_range is set to a wildcard on both rules, while destination_port_range is the actual port being protected. This trips a lot of people up at first. The source port is whatever random, temporary port your browser or SSH client happens to open the connection from, you cannot predict or control that, so it is always a wildcard. The destination port is the one you actually care about, the port on the server itself.

The Public IP

resource "azurerm_public_ip" "public-ip" {
  name                = "${var.project}_public_ip"
  resource_group_name = azurerm_resource_group.hagital-rg.name
  location            = azurerm_resource_group.hagital-rg.location
  allocation_method   = "Static"

  tags = {
    Environment = "public_ip"
    Owner       = var.owner
    Projects    = var.project
  }
}
Enter fullscreen mode Exit fullscreen mode

Without this, the VM would only be reachable on a private, internal address, nothing you could actually connect to from your own laptop. allocation_method is set to Static rather than Dynamic, deliberately. A static IP stays the same even if the VM is stopped and restarted, it only changes if the public IP resource itself is deleted. A dynamic IP, by contrast, can change every time the VM restarts, which becomes a real annoyance once you are actively developing against a known address.

The Network Interface

resource "azurerm_network_interface" "NIC" {
  name                = "${var.project}_NIC"
  location            = azurerm_resource_group.hagital-rg.location
  resource_group_name = azurerm_resource_group.hagital-rg.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.hagital-subnet.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.public-ip.id
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the piece that physically connects everything together. The network interface card, NIC for short, is what actually plugs the VM into the subnet, and it is also where the public IP gets attached. A VM cannot connect directly to a subnet or a public IP on its own, it always goes through a network interface in between.

The Virtual Machine Itself

resource "azurerm_linux_virtual_machine" "linux-vm" {
  name                = "${var.project}_linux_vm"
  computer_name       = "hagital-vm"
  resource_group_name = azurerm_resource_group.hagital-rg.name
  location            = azurerm_resource_group.hagital-rg.location
  size                = var.vm-size
  admin_username      = "${var.owner}user"
  network_interface_ids = [
    azurerm_network_interface.NIC.id
  ]

  admin_ssh_key {
    username   = "${var.owner}user"
    public_key = file("~/.ssh/id_rsa.pub")
  }

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

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts"
    version   = "latest"
  }
}
Enter fullscreen mode Exit fullscreen mode

This resource references everything built above it, the network interface for connectivity, an Ubuntu twenty two image from Canonical for the operating system, and an SSH key read directly from your local machine using the file() function, rather than pasting the key as text. One detail worth calling out, name and computer_name are two different things here, name is the label Azure uses for the resource itself, while computer_name becomes the actual hostname inside the running Linux machine, and Linux hostnames follow much stricter naming rules than Azure resource names do, more on that shortly when we get to the errors.

The Missing Piece Most Tutorials Skip

resource "azurerm_network_interface_security_group_association" "nsg_association" {
  network_interface_id      = azurerm_network_interface.NIC.id
  network_security_group_id = azurerm_network_security_group.NSG.id
}
Enter fullscreen mode Exit fullscreen mode

This resource genuinely tripped me up, and I want to explain it clearly because I suspect it will trip other people up too. Creating a network security group and writing rules for it does not automatically apply those rules to anything. The NSG just sits there, unused, until it is explicitly attached to something, in this case, the network interface. Without this association resource, your firewall rules exist in Azure but affect nothing at all, and Azure falls back to blocking inbound internet traffic by default. This one resource is what actually switches your firewall rules on.

outputs.tf, Getting Useful Information Back

output "resource_group_created" {
  description = "resource group name"
  value       = azurerm_resource_group.hagital-rg.name
}

output "Vnet" {
  description = "vnet name"
  value       = azurerm_virtual_network.hagital-vnet.name
}

output "subnet" {
  description = "subnet name"
  value       = azurerm_subnet.hagital-subnet.name
}

output "NSG" {
  description = "NSG name"
  value       = azurerm_network_security_group.NSG.name
}

output "port_80_rule" {
  description = "Inbound port 80 created"
  value       = azurerm_network_security_rule.NSG_rule_port_80.destination_port_range
}

output "port_22_rule" {
  description = "Inbound port 22 created"
  value       = azurerm_network_security_rule.NSG_rule_port_22.destination_port_range
}

output "public_ip" {
  description = "public ip"
  value       = azurerm_public_ip.public-ip.id
}

output "vm_name" {
  description = "azure vm name"
  value       = azurerm_linux_virtual_machine.linux-vm.name
}

output "vm_public_ip" {
  description = "vm attached public ip"
  value       = azurerm_linux_virtual_machine.linux-vm.public_ip_address
}
Enter fullscreen mode Exit fullscreen mode

Outputs print useful information to the terminal automatically after every apply, so you are not stuck manually looking things up in the Azure Portal afterward. The most useful one here by far is vm_public_ip, since that is the exact address needed to actually SSH into the machine once it exists.

The Errors I Ran Into, And How They Were Fixed

A quick note before this section, the code shown above already reflects every correction. You do not need to change anything, this section exists purely to walk through what went wrong the first time and how each issue was diagnosed.

This is the part most tutorials skip entirely, and it is the part I think actually matters most. Nothing here worked perfectly on the first attempt. Every error below is real, and understanding each one teaches you something a clean, error free run never would.

Running terraform init

terraform init
Enter fullscreen mode Exit fullscreen mode

This command downloads the azurerm provider and prepares the working directory. The very first time I ran this, it failed.

Error: Failed to query available provider packages
Could not retrieve the list of available versions for provider hashcorp/azurerm
Enter fullscreen mode Exit fullscreen mode

Read that carefully. It says hashcorp, missing the letter i. The real provider is published as hashicorp, with the i. Somewhere in my configuration, that name had been typed incorrectly, and Terraform was searching for a provider that simply does not exist. Correcting the spelling and running terraform init again resolved it immediately.

A second, completely different failure showed up on another attempt.

Error: Failed to install provider
Error while installing hashicorp/azurerm v4.80.0: releases.hashicorp.com: local error: tls: bad record MAC
Enter fullscreen mode Exit fullscreen mode

This one had nothing to do with my code at all, it was a network connection problem, an interrupted or unstable connection while downloading the provider. The fix here was simply waiting a moment and retrying the same command again.

terraform init
Enter fullscreen mode Exit fullscreen mode

Once the connection stabilized, it installed cleanly.

The Subscription ID Requirement

Running terraform plan for the first time in this particular project produced a new error I had not seen in earlier Terraform projects.

Error: `subscription_id` is a required provider property when performing a plan/apply operation
Enter fullscreen mode Exit fullscreen mode

This turned out to be a documented change, starting with version four point zero of the azurerm provider, explicitly setting a subscription ID in the provider block became mandatory. In an earlier Terraform project of mine, this had not come up, likely due to how that specific environment resolved it implicitly. The fix here was reverting cleanly to provider version 4.80.0 after clearing out the previously cached .terraform folder and lock file, forcing Terraform to properly resolve the version fresh, which sidestepped the issue entirely for this setup.

Reviewing The Plan

Once initialization succeeded, terraform plan produced a full, detailed preview of every resource about to be created, nine in total, resource group, virtual network, subnet, NSG, two security rules, public IP, network interface, and the VM itself.

Plan: 9 to add, 0 to change, 0 to destroy.
Enter fullscreen mode Exit fullscreen mode

This is the single most important habit in Terraform, always read the plan before applying it. It tells you exactly what is about to happen before it actually happens, and it is your last chance to catch a mistake before real infrastructure gets created and starts costing money.

Running terraform apply, And The First Real Failure

terraform apply
Enter fullscreen mode Exit fullscreen mode

Typing yes at the confirmation prompt kicked off creation. Eight resources succeeded cleanly, resource group, network, subnet, NSG, both rules, public IP, and network interface, each completing within seconds. Then the VM itself failed.

Error: unable to assume default computer name "computer_name" cannot contain the special characters:
`\/"[]:|<>+=;,?*@&~!#$%^()_{}'`. Please adjust the `name`, or specify an explicit `computer_name`
Enter fullscreen mode Exit fullscreen mode

This is a genuinely useful error to understand. Azure resource names and Linux hostnames follow completely different rule sets. My VM's name value contained underscores, which Azure resource names allow without issue, that is exactly why the resource group, VNet, and NSG all created successfully with underscores in their own names. But without an explicit computer_name set, Terraform tries to generate one automatically from the resource name, and a Linux hostname cannot contain underscores at all. The fix was adding an explicit, separate computer_name value using only letters, numbers, and hyphens, which is exactly what you see in the finished main.tf above, computer_name = "hagital-vm".

The Second Failure, An Invalid VM Size

After fixing the computer name and running terraform plan again, a different error appeared, this time about the VM size itself.

The originally typed size, Standard_B1_ls, is not a valid Azure size string. Azure VM sizes follow a fixed format defined by Azure itself, and this one had an extra underscore that did not belong. The correct value is Standard_B1ls, no separator between B one and ls, one continuous string. You can always confirm exactly which sizes are valid and available in your specific region directly from the CLI, rather than typing one from memory:

az vm list-sizes --location eastus --output table
Enter fullscreen mode Exit fullscreen mode

Correcting variables.tf to Standard_B1ls and running terraform plan again confirmed it was accepted.

Applying Again, Successfully This Time

terraform apply
Enter fullscreen mode Exit fullscreen mode

This time, all nine resources built successfully.

Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Outputs:

NSG = "terraform_vm_hagital_NSG"
Vnet = "terraform_vm_hagital_vnet"
port_22_rule = "22"
port_80_rule = "80"
public_ip = "/subscriptions/.../publicIPAddresses/terraform_vm_public_ip"
resource_group_created = "terraform_vm_hagital_rg"
subnet = "terraform_vm_hagital_subnet"
vm_name = "terraform_vm_linux_vm"
vm_public_ip = "20.169.155.100"
Enter fullscreen mode Exit fullscreen mode

Every output printed automatically, exactly as designed, including the actual public IP address, ready to copy straight into an SSH command.

The Connection That Timed Out

ssh Hagitaluser@20.169.155.100
Enter fullscreen mode Exit fullscreen mode
ssh: connect to host 20.169.155.100 port 22: Connection timed out
Enter fullscreen mode Exit fullscreen mode

This is the error that taught me the most out of this entire project. Everything in the plan had applied successfully, no errors, nine resources created, yet SSH could not reach the machine at all. A connection timeout like this, rather than an outright refusal, is the signature of a firewall silently dropping the request rather than actively rejecting it.

The cause traced back to something I had genuinely overlooked. Creating a network security group and writing rules for it does not automatically apply those rules to anything. I had built the NSG, and I had written both inbound rules, port twenty two and port eighty, but I had never actually attached that NSG to the network interface. The rules existed, but they were not switched on.

The fix was adding one more resource, exactly the association block shown earlier in main.tf.

resource "azurerm_network_interface_security_group_association" "nsg_association" {
  network_interface_id      = azurerm_network_interface.NIC.id
  network_security_group_id = azurerm_network_security_group.NSG.id
}
Enter fullscreen mode Exit fullscreen mode
terraform plan
terraform apply
Enter fullscreen mode Exit fullscreen mode

One resource added. Trying SSH again immediately after:

ssh Hagitaluser@20.169.155.100
Enter fullscreen mode Exit fullscreen mode
The authenticity of host '20.169.155.100 (20.169.155.100)' can't be established.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '20.169.155.100' (ED25519) to the list of known hosts.
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 6.8.0-1059-azure x86_64)
Enter fullscreen mode Exit fullscreen mode

Connected. That fingerprint warning the first time is completely normal, it is simply your machine confirming, for the first time, that it trusts this brand new server's identity, the same thing happens connecting to any new server for the first time, not just this one.

Configuring The Server

With SSH access working, I updated the system and installed Nginx, the actual web server this whole project was built to run.

sudo apt update
sudo apt upgrade -y
sudo apt install nginx -y
Enter fullscreen mode Exit fullscreen mode

Once installed, I confirmed the service was genuinely running, not just installed.

sudo systemctl status nginx
Enter fullscreen mode Exit fullscreen mode
 nginx.service - A high performance web server and a reverse proxy server
     Active: active (running) since Sun 2026-07-12 10:30:47 UTC
Enter fullscreen mode Exit fullscreen mode

Then verified it was actually serving content, first from directly inside the VM itself.

curl 20.169.155.100
Enter fullscreen mode Exit fullscreen mode
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
Enter fullscreen mode Exit fullscreen mode

Verifying From The Outside World

The final and most satisfying check, opening the VM's public IP address directly in a browser from my own laptop, completely outside of Azure and outside of the VM itself.

The page loaded correctly, the default Nginx welcome page, confirmed reachable from the open internet. That "not secure" warning next to the address bar is expected and not a problem, it simply means the connection is running over plain HTTP rather than HTTPS, adding a TLS certificate would be the natural next step to remove that warning, a good project on its own.

What This Project Actually Proves

Every piece of this was provisioned as code, version controlled, and repeatable, not clicked together by hand. Five distinct, real errors were hit along the way, a provider name typo, a network connectivity drop, a Linux hostname naming rule, an invalid Azure VM size string, and a missing NSG association that silently left the firewall rules inactive despite a fully successful apply. Every one of them was diagnosed by actually reading the error message closely and understanding what it was describing, not by guessing or copying a fix blindly.

The result is a real, working, secured Linux server, reachable over SSH, serving a live webpage to anyone on the internet, built entirely from four Terraform files and a lot of careful debugging.

If you followed along and built this yourself, or if you hit a different error than the ones documented here, I would genuinely like to hear about it in the comments. Debugging infrastructure is a skill built entirely through hitting exactly this kind of wall and working through it, and comparing notes with someone else doing the same thing is one of the fastest ways to get better at it.

Github Link:

Top comments (0)