Sitemap
Emele Chijioke

Emele Chijioke

DevOps Project: Deploying an Azure Infrastructure and Web Application using Terraform.

12 min readOct 26, 2026

--

Lesson 26: Introduction

This project demonstrates the end-to-end use of Terraform to provision a fully functional Linux virtual machine on Microsoft Azure. The lab covers writing Infrastructure-as-Code configuration, generating SSH keys for secure access, deploying a resource group with networking, network security group, public IP, network interface, and the virtual machine itself, connecting to the VM, installing and configuring the Apache web server, and hosting custom websites cloned from GitHub. A second, more modular Terraform project is then created to repeat the process for a PHP-oriented environment. The workflow illustrates the complete Terraform lifecycle (init, validate, plan, apply, destroy) and validates results both in the Azure Portal and through live browser access.

Tools Used
The following tools and services were employed throughout the project:

• Terraform — Declarative Infrastructure-as-Code tool used to define, plan, and provision all Azure resources.

• Azure CLI (az) — Command-line interface for authenticating and inspecting the active Azure subscription.

• Visual Studio Code — Primary IDE for editing Terraform configuration files and running integrated terminal commands.

• Microsoft Azure Portal — Web console used to visually verify created resources and their relationships.

• OpenSSH (ssh / ssh-keygen) — Tools for generating key pairs and securely connecting to the Linux virtual machine.

• Apache2 — Open-source web server installed on the Ubuntu VM to serve static and dynamic content.

• Git — Version-control system used to clone custom website repositories onto the VM.

• Web Browser — Used to validate that the hosted websites were publicly reachable via the VM’s public IP.

Labs
-
Creating a Linux VM with Terraform
- Deploying an IaaS web app

Steps
Creating a Linux VM with Terraform

> In the Terraform folder, create a folder called linux-vm
> In the linux-vm folder, create a file called main.tf

The VS Code Explorer shows the Terraform workspace with a dedicated “linux-vm” folder containing the main.tf configuration file.

> Copy and paste the code below into the main.tf file
> On line three of the code, enter your subscription.
> To get the subscription ID, run the command: az account list -o table

provider "azurerm" {
features {}
subscription_id = "b083090f-afd7-42eb-bba7-50d56150827f"
}


resource "azurerm_resource_group" "my_rg" {
name = "my-terraform-rg"
location = "westUS3"
}


resource "azurerm_virtual_network" "my_vnet" {
name = "my-vnet"
location = azurerm_resource_group.my_rg.location
resource_group_name = azurerm_resource_group.my_rg.name
address_space = ["10.0.0.0/16"]
}


resource "azurerm_subnet" "my_subnet" {
name = "my-subnet"
resource_group_name = azurerm_resource_group.my_rg.name
virtual_network_name = azurerm_virtual_network.my_vnet.name
address_prefixes = ["10.0.1.0/24"]
}


resource "azurerm_network_security_group" "ssh_sg" {
name = "ssh-nsg"
location = azurerm_resource_group.my_rg.location
resource_group_name = azurerm_resource_group.my_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 = "*"
}


# Allow HTTP traffic
security_rule {
name = "allow-http"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}


# Allow HTTPS traffic
security_rule {
name = "allow-https"
priority = 120
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}


# ✅ CRITICAL FIX (attach NSG to subnet)
resource "azurerm_subnet_network_security_group_association" "nsg_assoc" {
subnet_id = azurerm_subnet.my_subnet.id
network_security_group_id = azurerm_network_security_group.ssh_sg.id
}


# Create a Public IP
resource "azurerm_public_ip" "my_public_ip" {
name = "my-public-ip"
location = azurerm_resource_group.my_rg.location
resource_group_name = azurerm_resource_group.my_rg.name
allocation_method = "Static"
sku = "Standard"
}


# Create Network Interface with Public IP association
resource "azurerm_network_interface" "my_nic" {
name = "my-nic"
location = azurerm_resource_group.my_rg.location
resource_group_name = azurerm_resource_group.my_rg.name


ip_configuration {
name = "my-ip-config"
subnet_id = azurerm_subnet.my_subnet.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.my_public_ip.id # Associate Public IP
}


}


# Create a Linux VM
resource "azurerm_linux_virtual_machine" "my_vm" {
name = "my-linux-vm"
resource_group_name = azurerm_resource_group.my_rg.name
location = azurerm_resource_group.my_rg.location
size = "Standard_D2s_v3"
admin_username = "azureuser"
network_interface_ids = [ azurerm_network_interface.my_nic.id ]


admin_ssh_key {
username = "azureuser"
public_key = file("~/.ssh/id_rsa.pub")
}


source_image_reference {
publisher = "Canonical"
offer = "UbuntuServer"
sku = "18.04-LTS"
version = "latest"
}


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


tags = {
Environment = "Terraform"
}
}az account list -o table
Press enter or click to view image in full size

The main.tf file defines the AzureRM provider with the subscription ID, a resource group, virtual network, and subnet. The terminal output of “az account list” confirms that the same subscription ID is active, ensuring Terraform will target the correct Azure environment. Resource references are already linked so that networking components depend on the resource group.

Generating an SSH Key Pair
> Change directory to linux-vm
> Run the command ssh-keygen -t rsa
> Press Enter twice and type “y” if you want to overwrite.

cd linux-vm
ssh-keygen -t rsa
Press enter or click to view image in full size

The “ssh-keygen -t rsa” command creates a new RSA public/private key pair for secure password-less SSH access to the future Linux VM. The key is saved under the user’s .ssh directory, and the fingerprint is displayed.

Terraform Initialization and Validation
> Run the command: terraform init — upgrade
> Run the command: terraform validate

terraform init - upgrade
terraform validate
Press enter or click to view image in full size

Running “terraform init -upgrade” downloads the required AzureRM provider and creates the lock file. The subsequent “terraform validate” command reports that the configuration is syntactically valid, confirming the workspace is ready for planning and deployment.

Press enter or click to view image in full size

The “terraform plan” output lists the resources that will be created: resource group, virtual network, subnet, network security group, public IP, network interface, subnet-NSG association, and the Linux virtual machine. The summary “Plan: 8 to add, 0 to change, 0 to destroy” gives a clear preview of the infrastructure changes before any real resources are provisioned.

> Run the command: terraform apply
> Type Yes and hit the Enter button.

terraform apply
Press enter or click to view image in full size

After confirmation, “terraform apply” provisions all eight resources. Progress messages show each component being created, ending with “Apply complete! Resources: 8 added.” The Linux VM (my-linux-vm) is now running in Azure and ready for SSH access.

Verify In Azure portal

Press enter or click to view image in full size

The Azure Portal Resource Group “my-tf-rg” lists all deployed components: the virtual machine, OS disk, network interface, public IP address, virtual network, and network security group. This visual confirmation validates that Terraform’s state matches the live cloud environment.

To view ip address from terminal
> Run the command: az vm show -d -g my-terraform-rg -n my-linux-vm — query publicIps -o tsv

Connect to the VM
> Run the command: ssh azureuser@public-ip
> Type yes and hit Enter.
> Run the command: sudo apt update && sudo apt upgrade

ssh azureuser@public-ip
sudo apt update && sudo apt upgrade
Press enter or click to view image in full size

Using the public IP, the operator SSHs into the VM as azureuser. Once connected, “sudo apt update && sudo apt upgrade” is executed to bring the Ubuntu packages up to date. This step prepares a clean and secure base before installing additional software.

Installing and Starting Apache
> Run the command: sudo apt install apache2 -y
> Run the command: sudo systemctl enable apache2
> Run the command: sudo systemctl start apache2

sudo apt install apache2 -y
sudo systemctl enable apache2
sudo systemctl start apache2
Press enter or click to view image in full size

Apache2 is installed with apt, then enabled and started via systemctl. The commands “sudo systemctl enable apache2” and “sudo systemctl start apache2” ensure the web server runs automatically on boot and is immediately available. The VM is now ready to serve HTTP traffic on port 80.

Apache Default Page in Browser
> Open the IP in a web browser to see the Apache file

Press enter or click to view image in full size

Browsing to the VM’s public IP displays the classic “Apache2 Ubuntu Default Page.” This proves that the network security group correctly allows inbound HTTP traffic and that the web server is functioning as expected from an external client.

Inspecting the Default index.html

Get Emele Chijioke’s stories in your inbox

Join Medium for free to get updates from this writer.

> Run the command: cd /var/www/html/

cd /var/www/html/
Press enter or click to view image in full size

Inside the VM, the operator navigates to /var/www/html, lists the files, and views the content of the default index.html. This confirms the document root location that will later be replaced with a custom website.

Deploying a Custom Website from GitHub

> Run the command ls and cat index.html to see all the created files in the Apache file
> Run the command: sudo rm -f index.html
> Run the command: sudo git clone https://github.com/yourtechie/babycare-website.git .

ls
cat index.html
sudo rm -f index.html
sudo git clone https://github.com/yourtechie/babycare-website.git .
Press enter or click to view image in full size

The default index.html is removed, and a GitHub repository (babycare-website) is cloned directly into the web root. This replaces the Apache welcome page with a complete multi-page static site, demonstrating a simple continuous-deployment pattern using Git on the server.

Custom Baby-Care Website Live
Refresh the Apache Page to see the baby care website. If it doesn’t work, run the command below.

Press enter or click to view image in full size

Refreshing the browser now shows the professionally designed “We Care Your Baby” website. The page loads correctly over the public IP, confirming that the cloned content is being served by Apache and is publicly accessible.

Note: Run the command below if the ip doesnt open web app in the web browser

> Run the command: sudo chown -R www-data:www-data /var/www/html/
> Run the command: sudo chmod -R 755 /var/www/html/
> Run the command: sudo systemctl restart apache2

sudo chown -R www-data:www-data /var/www/html/
sudo chmod -R 755 /var/www/html/
sudo systemctl restart apache2

Clean Up.
> Exit from the linux-vm and run the command: terraform destroy.

Lab 2
Deploying an IaaS web app

Steps

> Open a new PowerShell terminal or change the directory to terraform
> Run the command: mkdir terraform-azure-php-lab
> Change directory to terraform-azure-php-lab. Run the command: cd terraform-azure-php-lab
> In terraform-azure-php-lab, create a file called variables.tf
> Copy and paste the code below, and save.
> In terraform-azure-php-lab, create a file called main.tf
> Copy and paste the code below, and save.
> In terraform-azure-php-lab, create a file called outputs.tf
> Copy and paste the code below, and save.

variable "location" {
default = "East US"
}


variable "vm_size" {
default = "Standard_B1s"
}


variable "admin_username" {
default = "azureuser"
}
provider "azurerm" {
features {}
}


resource "azurerm_resource_group" "rg" {
name = "php-app-rg"
location = var.location
}


resource "azurerm_virtual_network" "vnet" {
name = "php-vnet"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
}


resource "azurerm_subnet" "subnet" {
name = "php-subnet"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = ["10.0.1.0/24"]
}


# ---------------------------
# NSG ADDED HERE
# ---------------------------
resource "azurerm_network_security_group" "nsg" {
name = "php-vm-nsg"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
}


# Allow SSH
resource "azurerm_network_security_rule" "ssh" {
name = "Allow-SSH"
priority = 1001
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.rg.name
network_security_group_name = azurerm_network_security_group.nsg.name
}


# Allow HTTP
resource "azurerm_network_security_rule" "http" {
name = "Allow-HTTP"
priority = 1002
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.rg.name
network_security_group_name = azurerm_network_security_group.nsg.name
}


# Attach NSG to Subnet
resource "azurerm_subnet_network_security_group_association" "nsg_assoc" {
subnet_id = azurerm_subnet.subnet.id
network_security_group_id = azurerm_network_security_group.nsg.id
}
# ---------------------------


resource "azurerm_public_ip" "pip" {
name = "php-public-ip"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
allocation_method = "Static"
}


resource "azurerm_network_interface" "nic" {
name = "php-nic"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name


ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.subnet.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.pip.id
}
}


resource "azurerm_linux_virtual_machine" "vm" {
name = "php-vm"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
size = var.vm_size
admin_username = var.admin_username
network_interface_ids = [
azurerm_network_interface.nic.id
]


admin_password = "Password1234!"
disable_password_authentication = false


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


custom_data = base64encode(<<EOF
#!/bin/bash
apt update -y
apt install -y apache2 php git


cd /var/www/html
rm -f index.html


git clone https://github.com/yourtechie/charitize.git .


systemctl restart apache2
EOF
)
}

output "public_ip" {
value = azurerm_public_ip.pip.ip_address
}
Press enter or click to view image in full size

A new directory “terraform-azure-php-lab” is then created, containing main.tf, variables.tf and outputs.tf, preparing for a second, more modular deployment focused on a PHP-capable environment.

Initialize Terraform
> Run the command: terraform init

terraform init
Press enter or click to view image in full size

Inside the new directory, “terraform init” successfully installs the latest AzureRM provider (v5.11.0) and creates a fresh lock file. The workspace is now ready for planning the second set of infrastructure resources.

Deploy infrastructure
> Run the command: terraform plan

terraform plan
Press enter or click to view image in full size

The plan shows ten resources to be created, including a resource group, virtual network, subnet, network security group with HTTP and SSH rules, public IP, network interface, and a Linux virtual machine. An output for the public IP is also declared, making the address easily retrievable after applying.

> Run the command: terraform apply

terraform apply
Press enter or click to view image in full size

“terraform apply” provisions all ten resources successfully. The final output displays the public IP address (20.121.203.201), which can be used immediately to access the newly created virtual machine and any web content hosted on it.

Charitize Charity Website Live

Run the command: terraform output public_ip
Open in browser:http://PUBLIC_IP

Press enter or click to view image in full size

Browsing to the new public IP loads the “Charitize” charity website (“Together, We Can End Hunger”). This confirms that the second Terraform-managed Linux VM is fully operational and serving a custom web application, completing the lab objectives.

Clean Up.
> Run the command: terraform destroy.

terraform destroy

Challenges and Resolution

Challenge I: Resource naming conflicts

Description: Hard-coded names can collide when the same subscription is used for multiple labs or students.

Resolution: Used distinct prefixes (my-, php-) and, where appropriate, random providers for globally unique names.

Challenge II: NSG rules missing for HTTP

Description: Without an inbound rule allowing TCP port 80, the Apache default page cannot be reached from the internet.

Resolution: Explicitly defined NSG security rules for SSH (22) and HTTP (80) in the Terraform configuration.

Challenge III: Public IP changes after recreation

Description: Destroying and re-creating the infrastructure assigns a new public IP, breaking previous bookmarks or scripts.

Resolution: Used Terraform outputs to capture and display the current public IP after each apply.

Command Used

Press enter or click to view image in full size

Conclusion

This lab successfully demonstrated a complete Infrastructure-as-Code workflow for deploying production-ready Linux virtual machines on Azure. Starting from a clean Terraform configuration, the project provisioned networking, security groups, public IP addressing, and a fully patched Ubuntu VM, then installed Apache and hosted two different custom websites. The subsequent destruction of the first environment and the creation of a second, more modular Terraform project reinforced best practices around state management, resource naming, and output usage.

Key takeaways include the importance of reviewing the Terraform plan before applying changes, the value of SSH key-based authentication, the necessity of correctly configured network security group rules for web traffic, and the convenience of capturing public IP addresses via Terraform outputs. The combination of Terraform, Azure, Linux system administration, and Git provides a solid foundation for more advanced cloud deployments and continuous-delivery pipelines.

--

--