DEV Community

Cover image for From Terraform Code to a Real Azure VM: Building a Secure Azure Infrastructure with Terraform
olakunle makanjuola
olakunle makanjuola

Posted on

From Terraform Code to a Real Azure VM: Building a Secure Azure Infrastructure with Terraform

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     │
              └───────────────────┘
Enter fullscreen mode Exit fullscreen mode

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:

10.0.1.0/24

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:

ssh azureuser@4.221.66.140

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:

ssh azureuser@4.221.66.140

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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

DevOps #Terraform #Azure #InfrastructureAsCode #CloudComputing #Linux #SSH #AzureVM #DevOpsJourney

Top comments (0)