Azure Virtual Network and Linux VM with Terraform

Provision a full Azure network and Linux VM using Terraform code.

Introduction

30 Second Summary

Every time you need a new server or network in the cloud, you could click through dozens of portal screens. Or you could write a few lines of code that build everything for you in seconds, the same way every time.

In this project, you will write Terraform configuration files that provision a complete Virtual Network and Linux server on Azure with a single command.

What You'll Build

A complete Azure network infrastructure defined entirely in code using Infrastructure as Code with Terraform.

By the end of this project, you'll have:

  • A Terraform project that defines a Virtual Network, subnet, and Network Security Group as code.
  • A running Linux VM accessible via a public IP, deployed with one command.
  • A clean destroy workflow that removes all resources instantly with zero leftover charges.
  • Secret Mission: Add a private subnet with a second VM to demonstrate public vs private network architecture.

Are there any prerequisites?

You need basic familiarity with the command line and cloud concepts. You also need an Azure account (a free account with $200 credit works perfectly for this project).

Set Up the Terraform Project

Your tools are installed and you're authenticated with Azure. Now it's time to create the actual Terraform project that will define your infrastructure.

Terraform projects are just folders containing .tf files written in HCL (HashiCorp Configuration Language). You'll create three files that separate your configuration into logical pieces: provider setup, input variables, and outputs.

In this step, get ready to:
  • Create a project directory with three Terraform configuration files.
  • Write the provider configuration for Azure and define input variables.
  • Initialize Terraform to download the AzureRM provider plugin.
Create the project directory and files

Every Terraform project lives in its own directory. The files inside tell Terraform which cloud provider to use, what resources to create, and what information to display after deployment.

  • Switch back to your terminal and move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir terraform-azure-lab
cd terraform-azure-lab
  • Create three empty configuration files by running the following command:

macOS

touch main.tf variables.tf outputs.tf

Windows

New-Item -ItemType File -Path main.tf, variables.tf, outputs.tf
  • Confirm the files exist by running:
ls

You should see main.tf, variables.tf, and outputs.tf listed in the output.

Why three files?

Terraform reads all .tf files in a directory as a single configuration. Splitting code into separate files is a convention that keeps your project organized as it grows.

You'll use main.tf for provider setup and resources, variables.tf for configurable inputs, and outputs.tf for values Terraform displays after deployment.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the terraform-azure-lab folder, then click Open.

You should see the three files listed in the Explorer sidebar on the left.

Don't see the files in VS Code?

Make sure you opened the terraform-azure-lab folder itself, not the Desktop folder. The Explorer sidebar should show the folder name at the top with the three .tf files below it.

Help me troubleshoot this

Write the provider configuration and variable definitions

Terraform needs to know which cloud provider to talk to. The provider configuration tells Terraform to download the AzureRM plugin and authenticate using your Azure CLI credentials from Step 1.

  • In VS Code, click on main.tf in the Explorer sidebar to open it.
  • Paste the following provider configuration into main.tf:
terraform {
  required_version = ">= 1.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

What does this code do?

The terraform block sets two constraints. required_version = ">= 1.0" ensures anyone running this project has at least Terraform 1.0 installed.

The required_providers block tells Terraform to download the AzureRM plugin from hashicorp/azurerm with any version in the 4.x range. The ~> operator means "compatible with 4.0 but allow minor and patch updates."

The provider "azurerm" block with features {} activates the provider. The features {} block is required by AzureRM even if empty.

  • Save main.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Now you'll define the input variables that make your configuration reusable. Instead of hardcoding values like the Azure region or project name, variables let you change them in one place.

  • Click on variables.tf in the Explorer sidebar to open it.
  • Paste the following variable definitions into variables.tf:
variable "location" {
  description = "Azure region to deploy resources"
  type        = string
  default     = "East US"
}

variable "project_name" {
  description = "Name prefix for all resources"
  type        = string
  default     = "terraform-lab"
}

variable "vnet_address_space" {
  description = "Address space for the Virtual Network"
  type        = list(string)
  default     = ["10.0.0.0/16"]
}

variable "public_subnet_prefix" {
  description = "Address prefix for the public subnet"
  type        = list(string)
  default     = ["10.0.1.0/24"]
}

variable "admin_username" {
  description = "Admin username for the VM"
  type        = string
  default     = "azureuser"
}

What does this code do?

Each variable block defines one configurable input. The description explains what the variable is for. The type enforces what kind of value is accepted. The default provides a value that Terraform uses if you don't override it.

You defined five variables: the Azure region, a project name prefix, the Virtual Network address space, the subnet address prefix, and the VM admin username. Later steps will reference these using var.location, var.project_name, and so on.

  • Save variables.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).

What about outputs.tf?

You'll leave outputs.tf empty for now. You'll add output definitions in a later step once you have resources to display information about.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full main.tf at this point:

terraform {
  required_version = ">= 1.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

Here is the full variables.tf at this point:

variable "location" {
  description = "Azure region to deploy resources"
  type        = string
  default     = "East US"
}

variable "project_name" {
  description = "Name prefix for all resources"
  type        = string
  default     = "terraform-lab"
}

variable "vnet_address_space" {
  description = "Address space for the Virtual Network"
  type        = list(string)
  default     = ["10.0.0.0/16"]
}

variable "public_subnet_prefix" {
  description = "Address prefix for the public subnet"
  type        = list(string)
  default     = ["10.0.1.0/24"]
}

variable "admin_username" {
  description = "Admin username for the VM"
  type        = string
  default     = "azureuser"
}

The outputs.tf file should still be empty.

Initialize Terraform

Before Terraform can do anything with your configuration, it needs to download the provider plugin you specified. The terraform init command reads your main.tf, fetches the AzureRM provider, and prepares your working directory.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Initialize Terraform by running this command:
terraform init

You should see Terraform download the AzureRM provider and display the message: Terraform has been successfully initialized!

What did terraform init create?

Terraform created a hidden .terraform/ directory containing the downloaded AzureRM provider plugin. It also created a .terraform.lock.hcl file that locks the exact provider version for reproducibility.

You only need to run terraform init again if you change provider versions or add new providers.

Seeing a provider download error?

Make sure you saved main.tf before running the command. Terraform reads the file from disk, so unsaved changes in VS Code won't be picked up.

If you see a network error, check your internet connection. Terraform needs to download the provider from the Terraform Registry.

Help me troubleshoot this error

Your Terraform project is set up and ready to go. Next up, you'll define the actual Azure resources: a Resource Group, Virtual Network, subnet, and Network Security Group.

Define the Network Infrastructure

Your Terraform project is initialized and connected to Azure. Now it is time to define the actual cloud resources that will form your network infrastructure.

Every Azure deployment starts with a Resource Group. From there, you will build a Virtual Network, a subnet, a security group, and a network interface. These resources create the network foundation that your Linux VM will plug into in the next step.

In this step, get ready to:
  • Create a Resource Group and Virtual Network with a subnet.
  • Define a Network Security Group that allows HTTP traffic.
  • Set up a Public IP and Network Interface for your future VM.
Create the Resource Group and Virtual Network

In Azure, all resources must belong to a Resource Group. Think of it as a folder that holds related infrastructure together. Deleting the group deletes everything inside it.

The Virtual Network and subnet define the private IP address space your resources will communicate on.

  • In VS Code, open main.tf.
  • Below the provider "azurerm" block, add the Resource Group, Virtual Network, and Subnet by pasting this code:
# Resource Group
resource "azurerm_resource_group" "main" {
  name     = "${var.project_name}-rg"
  location = var.location
}

# Virtual Network
resource "azurerm_virtual_network" "main" {
  name                = "${var.project_name}-vnet"
  address_space       = var.vnet_address_space
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
}

# Public Subnet
resource "azurerm_subnet" "public" {
  name                 = "${var.project_name}-public-subnet"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = var.public_subnet_prefix
}

What does this code do?

  • azurerm_resource_group creates a container for all project resources. The name uses your project_name variable so everything is consistently named.
  • azurerm_virtual_network defines a private network with a 10.0.0.0/16 address space. That gives you over 65,000 available IP addresses.
  • azurerm_subnet carves out a smaller range (10.0.1.0/24) inside the VNet for public-facing resources.
  • Notice how each resource references the one above it. Terraform uses these references to figure out the correct creation order automatically.
  • Save main.tf.
  • You will not see a visible change yet. The next substep adds the security rules that protect this network.

Seeing red squiggles in your editor?

Make sure the new code is placed outside and below the closing } of the provider "azurerm" block. Terraform blocks cannot be nested inside each other.

Check that all opening braces { have a matching closing brace }. A missing brace causes cascading errors across the entire file.

help me fix a syntax error in my Terraform main.tf file

Create the Network Security Group

A Network Security Group (NSG) acts as a virtual firewall for your subnet. It controls which traffic is allowed in and out. Without it, your VM would be unreachable from the internet.

  • In main.tf, add the NSG and its subnet association below the subnet resource by pasting this code:
# Network Security Group
resource "azurerm_network_security_group" "web" {
  name                = "${var.project_name}-web-nsg"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "AllowHTTP"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "80"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

# Associate NSG with Subnet
resource "azurerm_subnet_network_security_group_association" "public" {
  subnet_id                 = azurerm_subnet.public.id
  network_security_group_id = azurerm_network_security_group.web.id
}

What does this code do?

  • The security_rule block allows inbound TCP traffic on port 80 (HTTP) from any source. This is what will let browsers reach your web server later.
  • Priority 100 means this rule is evaluated first. Lower numbers have higher priority in Azure NSGs.
  • The azurerm_subnet_network_security_group_association resource links the NSG to your public subnet. Without this association, the rules would exist but not be enforced anywhere.
  • Save main.tf.
  • You will not see a visible change yet. The next substep adds the connectivity layer that gives your VM a public address.

Getting an unexpected token error?

The security_rule block is nested inside the NSG resource. Make sure it is indented inside the azurerm_network_security_group block, not placed outside it.

Double-check that every string value is wrapped in double quotes. HCL does not accept single quotes.

help me fix my Network Security Group Terraform code

Create the Public IP and Network Interface

A Public IP gives your VM an address reachable from the internet. The Network Interface connects that public address to your subnet, acting as the virtual network card for your VM.

  • In main.tf, add the Public IP and Network Interface below the NSG association by pasting this code:
# Public IP
resource "azurerm_public_ip" "web" {
  name                = "${var.project_name}-web-pip"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

# Network Interface
resource "azurerm_network_interface" "web" {
  name                = "${var.project_name}-web-nic"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.public.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.web.id
  }
}

What does this code do?

  • azurerm_public_ip reserves a static IP address with Standard SKU. Static means the IP stays the same even if the VM restarts.
  • azurerm_network_interface is the virtual network card. Its ip_configuration block ties together the subnet (private side) and the public IP (internet side).
  • private_ip_address_allocation = "Dynamic" lets Azure pick an available private IP from the subnet range automatically.
  • Save main.tf.
  • Validate your configuration by running this command in your terminal:
terraform validate

You should see a success message confirming the configuration is valid.

Validation failed?

If you see an error about an unsupported argument or missing reference, check that every resource name matches exactly. For example, azurerm_resource_group.main must use the label main everywhere it is referenced.

If the error mentions an unexpected character, look for a missing comma or equals sign in the block that the error points to.

help me fix a terraform validate error in my networking configuration

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf and that terraform validate shows success.

ⓧ I'd like to double check the full code

Here is the complete main.tf at this point in the project:

terraform {
  required_version = ">= 1.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

# Resource Group
resource "azurerm_resource_group" "main" {
  name     = "${var.project_name}-rg"
  location = var.location
}

# Virtual Network
resource "azurerm_virtual_network" "main" {
  name                = "${var.project_name}-vnet"
  address_space       = var.vnet_address_space
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
}

# Public Subnet
resource "azurerm_subnet" "public" {
  name                 = "${var.project_name}-public-subnet"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = var.public_subnet_prefix
}

# Network Security Group
resource "azurerm_network_security_group" "web" {
  name                = "${var.project_name}-web-nsg"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "AllowHTTP"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "80"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

# Associate NSG with Subnet
resource "azurerm_subnet_network_security_group_association" "public" {
  subnet_id                 = azurerm_subnet.public.id
  network_security_group_id = azurerm_network_security_group.web.id
}

# Public IP
resource "azurerm_public_ip" "web" {
  name                = "${var.project_name}-web-pip"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

# Network Interface
resource "azurerm_network_interface" "web" {
  name                = "${var.project_name}-web-nic"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.public.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.web.id
  }
}

Your entire network stack is defined in code. Next up, you will add a Linux VM to this network and deploy everything to Azure with a single command.

Deploy the Linux VM and Verify

Your network infrastructure is in place with a Virtual Network, subnet, security group, and network interface all defined in code. Now it's time to deploy the main event: a Linux virtual machine running a web server.

The VM will use all the networking resources you defined in the previous step. Terraform handles the dependency graph automatically, creating resources in the right order so the VM lands on your network interface, inside your subnet, protected by your security group.

In this step, get ready to:
  • Define the Linux VM resource in main.tf.
  • Deploy all resources to Azure with a single command.
  • Verify the web server is accessible from your browser.
Define the Linux VM resource

The VM resource is the most complex block in your configuration. It references the network interface, specifies the machine size, sets up SSH authentication, chooses an operating system image, and includes a startup script that installs a web server.

Before writing the VM code, you need an SSH key pair on your machine. Azure Linux VMs use SSH keys for secure authentication instead of passwords.

  • Check if you already have an SSH key by running this command:
cat ~/.ssh/id_rsa.pub

✔️ I see a long key starting with ssh-rsa

Your SSH key is ready. Move on to the next instruction.

ⓧ No such file or directory

You need to generate an SSH key pair.

  • Generate a new SSH key by running:
ssh-keygen -t rsa -b 4096
  • Press Enter to accept the default file location (~/.ssh/id_rsa).
  • Press Enter twice more to skip setting a passphrase.
  • You should see a message confirming your key was saved to ~/.ssh/id_rsa.pub.
  • In main.tf, find the closing } of the azurerm_network_interface resource (the last resource in the file).
  • Add the following Linux VM resource below it:
# Linux Virtual Machine
resource "azurerm_linux_virtual_machine" "web" {
  name                            = "${var.project_name}-web-vm"
  resource_group_name             = azurerm_resource_group.main.name
  location                        = azurerm_resource_group.main.location
  size                            = "Standard_B1s"
  admin_username                  = var.admin_username
  disable_password_authentication = true

  network_interface_ids = [
    azurerm_network_interface.web.id,
  ]

  admin_ssh_key {
    username   = var.admin_username
    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-gen2"
    version   = "latest"
  }

  custom_data = base64encode(<<-EOF
    #!/bin/bash
    apt-get update -y
    apt-get install -y nginx
    systemctl start nginx
    systemctl enable nginx
    echo "<h1>Hello from Terraform on Azure!</h1><p>Hostname: $(hostname)</p>" > /var/www/html/index.html
    EOF
  )
}

What does this code do?

  • size = "Standard_B1s" selects Azure's free-tier eligible VM size (750 hours/month free for 12 months on new accounts).
  • admin_ssh_key reads your local public key file so Azure can authorize SSH connections to the VM without a password.
  • source_image_reference picks Ubuntu Server 22.04 LTS as the operating system.
  • custom_data is a startup script that runs once when the VM boots. It installs nginx, starts the web server, and writes a custom HTML page.
  • Save main.tf.
  • Confirm your syntax is correct by running:
terraform validate

You should see a message saying the configuration is valid.

Seeing a validation error?

  • Check that your SSH key file exists at ~/.ssh/id_rsa.pub. If Terraform cannot find it, you will see an error about the file() function.
  • Make sure you pasted the resource block after the closing } of the azurerm_network_interface resource, not inside it.
  • Confirm all opening braces { have matching closing braces }.

Help me fix a Terraform validation error in my Linux VM resource block.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.tf before moving on.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

# Resource Group
resource "azurerm_resource_group" "main" {
  name     = "${var.project_name}-rg"
  location = var.location
}

# Virtual Network
resource "azurerm_virtual_network" "main" {
  name                = "${var.project_name}-vnet"
  address_space       = var.vnet_address_space
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
}

# Public Subnet
resource "azurerm_subnet" "public" {
  name                 = "${var.project_name}-public-subnet"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = var.public_subnet_prefix
}

# Network Security Group
resource "azurerm_network_security_group" "web" {
  name                = "${var.project_name}-web-nsg"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "AllowHTTP"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "80"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

# Associate NSG with Subnet
resource "azurerm_subnet_network_security_group_association" "public" {
  subnet_id                 = azurerm_subnet.public.id
  network_security_group_id = azurerm_network_security_group.web.id
}

# Public IP
resource "azurerm_public_ip" "web" {
  name                = "${var.project_name}-web-pip"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

# Network Interface
resource "azurerm_network_interface" "web" {
  name                = "${var.project_name}-web-nic"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.public.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.web.id
  }
}

# Linux Virtual Machine
resource "azurerm_linux_virtual_machine" "web" {
  name                            = "${var.project_name}-web-vm"
  resource_group_name             = azurerm_resource_group.main.name
  location                        = azurerm_resource_group.main.location
  size                            = "Standard_B1s"
  admin_username                  = var.admin_username
  disable_password_authentication = true

  network_interface_ids = [
    azurerm_network_interface.web.id,
  ]

  admin_ssh_key {
    username   = var.admin_username
    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-gen2"
    version   = "latest"
  }

  custom_data = base64encode(<<-EOF
    #!/bin/bash
    apt-get update -y
    apt-get install -y nginx
    systemctl start nginx
    systemctl enable nginx
    echo "<h1>Hello from Terraform on Azure!</h1><p>Hostname: $(hostname)</p>" > /var/www/html/index.html
    EOF
  )
}
Define outputs and deploy

Terraform outputs let you extract useful information from your deployed resources. You will define outputs that display the resource group name, VNet name, and the VM's public IP so you can easily access your web server after deployment.

  • Open outputs.tf and add the following output definitions:
output "resource_group_name" {
  description = "Name of the resource group"
  value       = azurerm_resource_group.main.name
}

output "vnet_name" {
  description = "Name of the Virtual Network"
  value       = azurerm_virtual_network.main.name
}

output "web_server_public_ip" {
  description = "Public IP of the web server"
  value       = azurerm_public_ip.web.ip_address
}

output "web_server_url" {
  description = "URL to access the web server"
  value       = "http://${azurerm_public_ip.web.ip_address}"
}

What does this code do?

  • resource_group_name and vnet_name display the names Terraform generated from your variables, useful for referencing in the Azure Portal.
  • web_server_public_ip shows the static IP address Azure assigned to your VM.
  • web_server_url builds the full HTTP URL so you can copy-paste it directly into a browser.
  • Save outputs.tf.

✔️ Awesome, I've got everything!

Great. Double check you've saved outputs.tf before moving on.

ⓧ I'd like to double check the full code

output "resource_group_name" {
  description = "Name of the resource group"
  value       = azurerm_resource_group.main.name
}

output "vnet_name" {
  description = "Name of the Virtual Network"
  value       = azurerm_virtual_network.main.name
}

output "web_server_public_ip" {
  description = "Public IP of the web server"
  value       = azurerm_public_ip.web.ip_address
}

output "web_server_url" {
  description = "URL to access the web server"
  value       = "http://${azurerm_public_ip.web.ip_address}"
}

Now it's time to deploy. First, preview what Terraform will create.

  • Preview the deployment plan by running:
terraform plan

You should see a summary showing resources to be created (look for Plan: 8 to add, 0 to change, 0 to destroy or similar).

What is terraform plan?

The terraform plan command is a dry run. It shows you exactly what Terraform will create, modify, or destroy without making any actual changes. Always review the plan before applying.

  • Deploy all resources to Azure by running:
terraform apply

What will Terraform ask me?

Terraform will display the full execution plan and then prompt you with a confirmation. You will see:

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value:

Type yes and press Enter to proceed. Terraform will begin creating your resources.

The deployment takes 1-3 minutes. When it finishes, you will see your outputs printed in the terminal, including the public IP address.

Seeing an authentication error?

  • Your Azure CLI session may have expired. Run az login again to re-authenticate, then retry terraform apply.
  • If you see an error about the subscription, run az account show to confirm you are signed in to the correct Azure account.

Help me fix an authentication error when running terraform apply with Azure.

Verify the deployment

Your infrastructure is live. The VM is running, nginx is installed, and a custom HTML page is being served. Time to confirm everything works.

  • Look at the Terraform output in your terminal. Find the web_server_url value (it will look like http:// followed by an IP address).
  • Copy that URL and paste it into your browser's address bar.

You should see a page displaying "Hello from Terraform on Azure!" followed by the hostname of your VM.

What if the page doesn't load immediately?

The VM's custom_data script needs time to finish running after the VM boots. If the page does not load on the first try, wait 30-60 seconds and refresh.

The nginx installation and configuration happens asynchronously after Terraform reports the VM as created.

  • Open the Azure Portal at portal.azure.com in your browser.
  • Search for Resource groups in the top search bar.
  • Click on terraform-lab-rg to see all the resources Terraform created.

You should see all 7 resources listed: the VNet, subnet, NSG, public IP, NIC, NSG association, and the VM itself.

Page not loading after a minute?

  • Confirm your NSG allows inbound port 80. In the Azure Portal, open the terraform-lab-web-nsg resource and check the inbound security rules.
  • Make sure you are using http:// (not https://) in your browser. The NSG only allows port 80 (HTTP), not 443 (HTTPS).
  • Verify the VM is running in the Azure Portal by navigating to the VM resource and checking its status shows Running.

Help me troubleshoot why my nginx page is not loading on my Azure VM's public IP.

Secret mission

Add a Private Subnet with a Second Linux VM

Your web server has a public IP so the world can reach it. But in production, not every server should be internet-facing. Can you add a private subnet with a second VM that has no public IP, demonstrating the difference between public and private network architecture?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The B1s VM is free tier eligible for 750 hours per month during your first 12 months on Azure. However, the Standard SKU public IP incurs a small hourly charge as long as it exists.

Cost warning

Standard SKU public IP addresses incur charges regardless of whether the VM is running. If you leave resources deployed beyond your free tier limits, additional charges will apply. The recommended option for a learning project is to destroy everything.

Resources you used:

  • 1 Resource Group (terraform-lab-rg).
  • 1 Virtual Network (terraform-lab-vnet).
  • 2 Subnets (public and private).
  • 1 Network Security Group and subnet association.
  • 1 Standard SKU Public IP (terraform-lab-web-pip).
  • 2 Network Interfaces (web and private).
  • 2 Linux VMs (terraform-lab-web-vm and terraform-lab-priv-vm).
  • Local project folder with main.tf, variables.tf, and outputs.tf.

Keep everything running

No action needed. Choose this if you are still actively experimenting with your Terraform configuration or want to keep the VM running for testing.

  • Monitor your free tier usage in the Azure Portal under Cost Management + Billing.
  • The B1s VMs are free for 750 hours per month for new accounts (first 12 months).
  • The Standard SKU public IP will incur a small hourly charge as long as it exists.
  • Your local project folder and Terraform state file remain on your machine.

Pause - I'll come back to this later

Deallocate the VMs to stop compute charges while keeping your infrastructure definition intact. You will still pay a minimal amount for disk storage.

  • In the Azure Portal, navigate to Virtual Machines.
  • Select terraform-lab-web-vm and click Stop in the toolbar.
  • Repeat for terraform-lab-priv-vm.
  • Wait for both VMs to show a Stopped (deallocated) status.
  • Your local project files and Terraform state remain intact. When you are ready to resume, run terraform apply to bring everything back up.

Delete - I don't want to use this again

Remove all project resources completely. This is the recommended option for a learning project.

  • In your terminal, navigate to your terraform-lab project folder.
  • Destroy all Azure resources by running:
terraform destroy
  • Terraform will show you a plan of everything it will delete. Type yes and press Enter to confirm.
  • Wait for the destroy to complete. You should see a message confirming all resources have been destroyed.

Backup option if terraform destroy fails

If Terraform has trouble destroying a resource, you can delete the entire resource group in the Azure Portal. Navigate to Resource Groups, select terraform-lab-rg, and click Delete resource group. This removes everything inside it.

  • Delete the local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf terraform-lab
  • On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force terraform-lab
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) in your Desktop directory. You should no longer see terraform-lab listed.

That's a Wrap!

That's a Wrap!

You just provisioned real Azure infrastructure entirely from code. With three configuration files and a single command, you deployed a full network stack and a running Linux VM.

Here's what you learned:

  • Built a Resource Group, Virtual Network, subnet, and Network Security Group using Terraform configuration files.
  • Deployed a Linux VM running nginx accessible via a public IP, all created with a single terraform apply command.
  • Mastered the core Terraform workflow: init, plan, apply, and destroy to manage infrastructure as code with variables, outputs, and state.
  • Added a private subnet with a second VM that has no public IP, demonstrating public vs. private network architecture.

Ready to quiz yourself?