Introduction
Infrastructure as Code (IaC) has changed the way modern cloud infrastructure is designed, deployed, and managed.
Instead of manually logging into the Azure Portal, clicking through multiple screens, creating a virtual network, configuring a subnet, creating a public IP, setting up a Network Security Group, creating a network interface, and finally deploying a virtual machine, Terraform allows us to describe the infrastructure in code and provision it automatically.
In this project, I wanted to move beyond simply learning Terraform commands and actually use Terraform to build a real Azure infrastructure.
The goal was to provision an Azure environment containing:
An Azure Resource Group
A Virtual Network (VNet)
A Subnet
A Public IP address
A Network Security Group (NSG)
A Network Interface Card (NIC)
An NSG-to-NIC association
An Ubuntu Linux Virtual Machine
SSH key-based authentication
At the end of the project, I successfully deployed a real Ubuntu 22.04 virtual machine inside an Azure Virtual Network, protected it with an NSG, assigned it a public IP address, and successfully connected to it remotely using SSH.
This article documents the entire process, including the errors I encountered along the way, what caused them, and how I resolved them.
Project Objectives
The main objectives of this project were to:
Learn how to provision Azure infrastructure using Terraform.
Understand how Terraform resources depend on one another.
Create an Azure Virtual Network and subnet.
Configure a Network Security Group to control inbound traffic.
Create a public IP address for the virtual machine.
Create and configure a Network Interface.
Associate the NSG with the NIC.
Provision an Ubuntu Linux VM using Terraform.
Configure SSH key-based authentication.
Connect to the provisioned VM remotely through SSH.
Understand the relationship between Azure networking components.
Troubleshoot real Terraform, Azure, and SSH errors.
Tools Used And Deployed
The project was built using:
Microsoft Azure
Terraform
AzureRM Terraform Provider
Ubuntu 22.04 LTS
Git Bash
SSH
Azure Virtual Network
Network Security Group
Azure Public IP
Azure Network Interface
My Terraform environment was running on Windows using Git Bash.
Terraform was used as the Infrastructure as Code tool, while Azure provided the actual cloud infrastructure.
Understanding the Architecture
Before writing the Terraform configuration, it was important to understand how the Azure resources would connect to each other.
The architecture was:
INTERNET
│
│
Public IP Address
4.221.66.140
│
▼
┌───────────────┐
│ NIC │
│ flackern-nic │
└───────┬───────┘
│
NSG Association
│
▼
┌───────────────┐
│ NSG │
│ flackern-nsg │
│ │
│ TCP/22 Allow │
└───────────────┘
│
▼
┌─────────────────────┐
│ Azure VM │
│ flackern-vm │
│ │
│ Ubuntu 22.04 │
│ azureuser │
└──────────┬──────────┘
│
│
Inside Subnet
│
▼
┌───────────────────┐
│ flackern-subnet │
│ 10.0.1.0/24 │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ flackern-vnet │
│ 10.0.0.0/16 │
└───────────────────┘
The important traffic flow is:
Internet
↓
Public IP
↓
Network Interface
↓
Network Security Group
↓
Virtual Machine
The NSG controls whether network traffic is allowed to reach the VM.
In this project, TCP port 22 was opened for SSH access.
Step 1 — Creating the Terraform Configuration
I started by defining the Terraform and AzureRM provider configuration.
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.5.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = "YOUR-SUBSCRIPTION-ID"
}

For security reasons, I have replaced my actual subscription ID in this article.
The AzureRM provider allows Terraform to communicate with Microsoft Azure and create Azure resources.
Step 2 — Creating the Resource Group
The first Azure resource I created was the Resource Group.
resource "azurerm_resource_group" "flackern-rg" {
name = "flackern-rg"
location = "south Africa North"
}
The Resource Group provides a logical container for the Azure resources used in the project.
Step 3 — Creating the Virtual Network
Next, I created the Virtual Network.
resource "azurerm_virtual_network" "flackern-vnet" {
name = "flackern-vnet"
location = azurerm_resource_group.flackern-rg.location
resource_group_name = azurerm_resource_group.flackern-rg.name
address_space = ["10.0.0.0/16"]
}
The VNet uses:
10.0.0.0/16
This provides the private network address space for the infrastructure.
Step 4 — Creating the Subnet
Inside the Virtual Network, I created a subnet.
resource "azurerm_subnet" "flackern-subnet" {
name = "flackern-subnet"
resource_group_name = azurerm_resource_group.flackern-rg.name
virtual_network_name = azurerm_virtual_network.flackern-vnet.name
address_prefixes = ["10.0.1.0/24"]
}
The subnet uses:
Step 5 — Creating the Public IP
The VM needed a public IP so that I could connect to it remotely from my computer.
resource "azurerm_public_ip" "flackern-ip" {
name = "flackern-ip"
resource_group_name = azurerm_resource_group.flackern-rg.name
location = azurerm_resource_group.flackern-rg.location
allocation_method = "Static"
sku = "Standard"
}
Azure eventually assigned:
4.221.66.140
to the VM.
Step 6 — Creating the Network Security Group
Next, I created the Network Security Group.
resource "azurerm_network_security_group" "flackern-nsg" {
name = "flackern-nsg"
location = azurerm_resource_group.flackern-rg.location
resource_group_name = azurerm_resource_group.flackern-rg.name
security_rule {
name = "Allow-SSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = ""
destination_port_range = "22"
source_address_prefix = ""
destination_address_prefix = "*"
}
}
The important part is:
destination_port_range = "22"
Port 22 is the standard SSH port.
This rule allows inbound TCP traffic on port 22.
Step 7 — Creating the Network Interface
The VM needs a network interface to communicate with the Azure network.
I created the NIC with:
resource "azurerm_network_interface" "flackern-nic" {
name = "flackern-nic"
location = azurerm_resource_group.flackern-rg.location
resource_group_name = azurerm_resource_group.flackern-rg.name
ip_configuration {
name = "flackern-ipconfig"
subnet_id = azurerm_subnet.flackern-subnet.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.flackern-ip.id
}
}
The NIC connects the VM to the subnet.
The relationship is:
VNet
↓
Subnet
↓
NIC
↓
VM
The NIC also connects the VM to the public IP.
Step 8 — Associating the NSG with the NIC
Creating an NSG does not automatically mean that the VM's NIC is associated with it.
I explicitly created the association:
resource "azurerm_network_interface_security_group_association" "flackern-nic-nsg" {
network_interface_id = azurerm_network_interface.flackern-nic.id
network_security_group_id = azurerm_network_security_group.flackern-nsg.id
}
This connects:
NSG
↓
NIC
↓
VM
Therefore, the SSH rule in the NSG can control traffic reaching the VM through its network interface.
Step 9 — Creating the SSH Key Pair
For secure access to the Linux VM, I generated an SSH key pair.
From Git Bash:
ssh-keygen -t ed25519 -C "terraform-azure-vm"
This produced two files:
terraform-azure
terraform-azure.pub
The difference is important.
Private key
terraform-azure
This stays on my computer and must be kept secret.
Public key
terraform-azure.pub
This can be installed on the Azure VM.
I verified the files with:
ls -la ~/.ssh
The result included:
terraform-azure
terraform-azure.pub
Step 10 — Configuring the Linux VM
I then created the Ubuntu VM.
resource "azurerm_linux_virtual_machine" "flackern-vm" {
name = "flackern-vm"
resource_group_name = azurerm_resource_group.flackern-rg.name
location = azurerm_resource_group.flackern-rg.location
size = "Standard_B2ats_v2"
admin_username = "azureuser"
network_interface_ids = [
azurerm_network_interface.flackern-nic.id,
]
admin_ssh_key {
username = "azureuser"
public_key = file("~/.ssh/terraform-azure.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"
}
}
The VM uses Ubuntu 22.04 LTS.
The administrator username is:
azureuser
The VM receives the public SSH key generated earlier.
Step 11 — Creating the VM Public IP Output
To make it easy to retrieve the public IP after deployment, I added an output:
output "vm_public_ip" {
value = azurerm_public_ip.flackern-ip.ip_address
}
After running Terraform, the output was:
vm_public_ip = "4.221.66.140"
This made it easy to connect to the VM.
*Step 12 — Running Terraform
*
Once the configuration was ready, I initialized Terraform:
terraform init
Then I formatted the configuration:
terraform fmt
Then I validated it:
terraform validate
Terraform returned:
Success! The configuration is valid.
I then generated the execution plan:
terraform plan
After reviewing the planned resources, I applied the configuration:
terraform apply
Terraform eventually returned:
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
This was the moment the infrastructure was actually provisioned in Azure.
Pictorial Diagram of Successful Resource Provision On Azure Portal Using Terraform
Log into https://portal.azure.com/ to confirm the successful creation of these resource
Step 13 — First SSH Connection Attempt
After Terraform successfully created the infrastructure, I attempted to connect to the VM:
I was prompted to verify the host:
The authenticity of host '4.221.66.140' can't be established.
Are you sure you want to continue connecting
(yes/no/[fingerprint])?
I entered:
yes
The server was then added to my local SSH known_hosts file.
However, the connection failed:
azureuser@4.221.66.140: Permission denied (publickey).
Step 14 — Successful SSH Connection
This time the connection succeeded.
I received the Ubuntu welcome message:
Welcome to Ubuntu 22.04.5 LTS
and eventually reached:
azureuser@flackern-vm:~$
This confirmed that I was now inside my Azure VM.
The VM reported its private IP as:
10.0.1.4
while the public IP was:
4.221.66.140
This demonstrated the difference between a VM's private network address and its public Internet-facing address.
SOME OF THE ERROR ENCOUNTERED IN THE COURSE OF CARRYING OUT THIS PROJECT
Error 1 — security_rule Unsupported Block Type
This was one of my first Terraform errors.
Initially, I accidentally placed the security_rule block outside the Network Security Group resource.
Terraform returned an error similar to:
Error: Unsupported block type
Blocks of type "security_rule" are not expected here.
What caused the problem?
The structure was effectively:
resource "azurerm_network_security_group" "flackern-nsg" {
...
}
security_rule {
...
}
Terraform interpreted security_rule as a standalone block.
But security_rule belongs inside the azurerm_network_security_group resource.
How I fixed it
I moved the entire block inside the NSG:
resource "azurerm_network_security_group" "flackern-nsg" {
...
security_rule {
name = "Allow-SSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = ""
destination_port_range = "22"
source_address_prefix = ""
destination_address_prefix = "*"
}
}
This taught me an important Terraform lesson:
Terraform configuration is hierarchical. A block must be placed inside the resource that supports it.
Error 2 — Resource Name Mismatch
I also encountered errors caused by inconsistent Terraform resource labels.
At different points I had references such as:
flakern-rg
and:
flackern-rg
I had made a similar mistake with the NSG:
flakern-nsg
versus:
flackern-nsg
Terraform resource references must match the declared resource label exactly.
For example:
resource "azurerm_resource_group" "flackern-rg" {
must be referenced as:
azurerm_resource_group.flackern-rg.name
not:
azurerm_resource_group.flakern-rg.name
How I fixed it
I standardized the naming convention throughout the Terraform configuration:
flackern-rg
flackern-vnet
flackern-subnet
flackern-ip
flackern-nsg
flackern-nic
flackern-vm
After correcting the references, I ran:
terraform fmt
followed by:
terraform validate
Terraform eventually returned:
Success! The configuration is valid.
*Error 3 — Public IP Resource Reference Was Treated as a String
*
During terraform plan, I encountered another important error.
I initially wrote:
public_ip_address_id = "azurerm_public_ip.flackern-ip.id"
Terraform interpreted this as a literal string rather than a Terraform resource reference.
This caused an error involving the parsing of the Public IP resource ID.
Why?
Terraform distinguishes between strings and expressions.
This:
"azurerm_public_ip.flackern-ip.id"
means:
Treat this entire thing as text.
Whereas this:
azurerm_public_ip.flackern-ip.id
means:
Retrieve the ID of this Terraform resource.
The correction
I removed the quotation marks:
public_ip_address_id = azurerm_public_ip.flackern-ip.id
I then ran:
terraform fmt
terraform validate
terraform plan
The plan proceeded successfully.
This was an important lesson for me:
Terraform resource references should not be placed inside quotation marks.
Error 4 — SSH Permission Denied (publickey)
This was not a Terraform deployment failure.
The VM was running and reachable, but SSH authentication was failing.
The important distinction was:
Network connection: SUCCESS
SSH authentication: FAILED
The solution was to explicitly tell SSH which private key to use.
Instead of:
I used:
ssh -i ~/.ssh/terraform-azure azureuser@4.221.66.140
The difference is the:
-i
option.
It tells SSH which private identity file to use.
What I Learned From This Project
This project taught me much more than simply how to write Terraform syntax.
- Infrastructure as Code
I learned that infrastructure can be described in code and deployed consistently rather than manually created through the Azure Portal.
For example:
resource "azurerm_virtual_network" "flackern-vnet" {
...
}
is not just configuration.
It represents an actual Azure resource that Terraform manages.
- Terraform Dependencies
I also learned how Terraform understands relationships between resources.
For example:
Resource Group
↓
Virtual Network
↓
Subnet
↓
NIC
↓
VM
Terraform can determine the dependency relationships because resources reference one another.
- Azure Networking
The project helped me understand the relationship between:
VNet
Subnet
NIC
Public IP
NSG
VM
Before building this project, these components could seem like separate Azure services.
Building the infrastructure made their relationship much clearer.
- Network Security Groups
I learned that an NSG acts as a traffic filtering mechanism.
In this project, the NSG allowed:
Inbound TCP
Port 22
which enabled SSH access to the VM.
- SSH Authentication
I also learned the practical difference between an SSH public key and private key.
Public key
↓
Azure VM
Private key
↓
My computer
The private key is used to prove that I am authorized to access the VM.
- Troubleshooting Is Part of Engineering
Perhaps one of the most valuable lessons was that real-world infrastructure work does not always work on the first attempt.
I encountered:
Unsupported block type
then:
Resource reference/name mismatch
then:
Public IP ID parsing error
and finally:
Permission denied (publickey)
Each error provided an opportunity to understand what was actually happening rather than simply copying commands.
The Final Terraform Workflow
The workflow I followed can be summarized as:
Write Terraform Configuration
↓
terraform init
↓
terraform fmt
↓
terraform validate
↓
terraform plan
↓
terraform apply
↓
Azure Infrastructure Created
↓
Retrieve Public IP
↓
SSH into Ubuntu VM
↓
SUCCESS
This is a workflow I can now reuse for future Azure projects.
Conclusion
This project was an important step in my journey into Cloud and DevOps engineering.
I started with Terraform configuration and ended with a real Ubuntu server running inside Microsoft Azure.
More importantly, I learned that Infrastructure as Code is not simply about memorizing Terraform commands. It is about understanding infrastructure, dependencies, networking, security, authentication, automation, and troubleshooting.
The project also reinforced an important engineering principle:
When something fails, understand the error before trying to fix it.
Every error I encountered helped me understand Terraform and Azure more deeply.
This project started as a Terraform learning exercise, but it became something much more valuable: a practical demonstration of how Infrastructure as Code can turn source code into real cloud infrastructure.
One VM. One Virtual Network. One NSG. One public IP. All provisioned from code.
And this is only the beginning of my DevOps journey.
















Top comments (0)