Deploy a Linux Web Server with Terraform
Provision a Linux VM running Nginx on Azure using Terraform IaC.
Introduction
30 Second Summary
Every time you click around a cloud portal to set up a server, you are creating work that cannot be easily repeated, shared, or undone. One wrong click and you might spend hours figuring out what changed.
In this project, you will use Terraform to deploy a Linux web server on Azure entirely from code. You will write configuration files that create networking, security, and a virtual machine running Nginx, then verify your live web page in a browser.
What You'll Build
A Linux web server on Azure, provisioned entirely through Terraform configuration files following the Infrastructure as Code approach.
By the end of this project, you'll have:
- A Terraform configuration that creates a full Azure networking stack including a virtual network, subnet, security group, and public IP.
- A Linux virtual machine running Nginx, deployed and bootstrapped automatically through cloud-init.
- A working understanding of the Terraform lifecycle (init, plan, apply, destroy) for managing cloud infrastructure.
- A refactored configuration using variables and outputs for multi-environment reuse (Secret Mission).
Are there any prerequisites?
You need an Azure account with an active subscription. You should also have macOS with Homebrew available. We will install Terraform and the Azure CLI in Step 1.
Install Terraform, Azure CLI, and VS Code
Every piece of infrastructure in this project gets defined in code, not clicked through a portal. Terraform reads your configuration files and provisions the exact Azure resources you describe.
Before writing any configuration, you need three tools on your machine. Terraform handles the provisioning. Azure CLI authenticates you so Terraform can manage resources in your account. VS Code gives you a code editor with syntax highlighting for Terraform files.
In this step, get ready to:
- Install Terraform and Azure CLI using Homebrew.
- Set up VS Code with the Terraform extension for editing configuration files.
- Authenticate to your Azure account from the terminal.
Install Terraform and Azure CLI via Homebrew
Homebrew is a package manager for macOS that lets you install developer tools from the terminal. Both Terraform and Azure CLI are available through Homebrew.
- Check if Homebrew is already installed by running this command:
brew --version
I see a version number
Homebrew is ready. Continue to the Terraform install below.
Command not found
- Install Homebrew by running this command:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
What happens during this install?
The installer asks for your macOS password and shows a summary of what it plans to install. Press Enter when prompted to continue. The installation takes 1-2 minutes.
- After the install finishes, verify Homebrew is working by running:
brew --version
You should see a version number in the output.
- Add the HashiCorp package repository and install Terraform by running these commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
What do these commands do?
- brew tap hashicorp/tap registers HashiCorp's official package repository with Homebrew. This is where Terraform releases are published.
- brew install hashicorp/tap/terraform downloads and installs the Terraform CLI from that repository.
- Verify Terraform is installed by running:
terraform --version
You should see a version number starting with 1. in the output.
Terraform command not found?
- Close and reopen your terminal, then try again. New installations sometimes need a fresh terminal session.
- Scroll up in your terminal and check whether the brew install command completed without errors.
- Terraform isn't recognized after installing via Homebrew. How do I fix this?
- Install Azure CLI by running this command:
brew install azure-cli
This installation takes 2-3 minutes to complete.
- Verify Azure CLI is installed by running:
az --version
You should see azure-cli followed by a version number at the top of a list of components.
az command not found?
- Close and reopen your terminal, then try the command again.
- If the issue persists, run brew reinstall azure-cli to reinstall.
- Azure CLI isn't recognized after installing via Homebrew. What should I check?
Install VS Code and the Terraform extension
- Check if VS Code is already installed by running this command:
code --version
I see a version number
VS Code is ready. Skip to installing the Terraform extension below.
Command not found
- Download VS Code from the official website.
- Click the download button for macOS.
- Open the downloaded .zip file and drag the VS Code app into your Applications folder.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
Setting up the code command
To use the code command in your terminal, open VS Code and press Cmd+Shift+P to open the Command Palette. Type shell command and select Shell Command: Install 'code' command in PATH.
The Terraform extension for VS Code adds syntax highlighting, autocomplete, and formatting for HCL, the language Terraform configuration files use.
- In VS Code, click the Extensions icon in the left sidebar (or press Cmd+Shift+X on macOS, Ctrl+Shift+X on Windows).
- Type HashiCorp Terraform in the search bar.
- Click Install on the HashiCorp Terraform extension by HashiCorp.
What does this extension do?
The extension highlights your .tf files with color-coded syntax so you can spot errors quickly. It also autocompletes resource names and attribute fields as you type, saving you from memorizing every configuration option.
Authenticate to Azure
Terraform needs access to your Azure account to create resources. The simplest way to authenticate locally is through Azure CLI, which Terraform detects automatically.
- Log in to Azure by running this command:
az login
What happens when you run this?
Azure CLI opens your default browser to a Microsoft sign-in page. Sign in with the Azure account that has your active subscription. After signing in, the browser shows a success message. Return to your terminal to continue.
- Confirm you are connected to the correct subscription by running:
az account show
You should see JSON output with your subscription details, including a name field showing your subscription name and an id field with your subscription ID.
- Find the id field in the output. This is your subscription ID.
- Copy this value and keep it somewhere handy. You will need it in the next step when you configure the Terraform provider.
Pro tip: grab it fast
You can extract just the subscription ID by running:
az account show --query id --output tsv
This prints only the ID with no surrounding JSON, making it easy to copy.
Wrong subscription or login failed?
- If the name field shows a different subscription than expected, list all available subscriptions and switch to the correct one:
az account list --output table
az account set --subscription "your-subscription-id"
- If the browser did not open during az login, try running az login --use-device-code instead. This gives you a URL and code to enter manually.
- I'm having trouble authenticating to Azure with az login. How do I fix this?
Your environment is ready and your terminal is authenticated to Azure. Next up, you will write your first Terraform configuration file and connect it to the Azure provider.
Write the Provider Configuration
Your tools are installed and you are authenticated to Azure. Now it is time to write your first Terraform code.
Every Terraform project starts with a provider configuration. This tells Terraform which cloud platform to talk to and how to authenticate. You will create a project folder, write the configuration in HCL (HashiCorp Configuration Language), and run your first Terraform command.
In this step, get ready to:
- Create a project directory and a main.tf file with the AzureRM provider configuration.
- Run terraform init to download the provider plugin and confirm Terraform is ready to go.
Create the project directory and provider file
Terraform reads .tf files from the current directory and uses them to figure out what infrastructure to create. You need a dedicated folder for your project and a main.tf file to hold the configuration.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir terraform-webserver
cd terraform-webserver
- Confirm the folder was created by running:
pwd
You should see a path ending in terraform-webserver.
- Open VS Code by pressing 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-webserver folder, then click Open.
You should see terraform-webserver in the Explorer sidebar on the left. The folder is empty for now.
- Create a new file by clicking the new file icon at the top of the Explorer sidebar (it appears when you hover over the folder name).
- Name the file main.tf and press Enter.
Why main.tf?
Terraform loads every .tf file in the current directory automatically. The name main.tf is a convention, not a requirement. It signals "this is the primary configuration file" to anyone reading your project.
The file should open in the editor. If the HashiCorp Terraform extension you installed in Step 1 is active, you will see HCL syntax highlighting as you type.
- Add the provider configuration to main.tf by copying the following code into the file:
# Declare the required provider and version
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
required_version = ">= 1.2"
}
# Configure the Azure provider with your subscription
provider "azurerm" {
features {}
subscription_id = "your-subscription-id-here"
}
What does this code do?
- The terraform block tells Terraform to use the AzureRM provider from HashiCorp's registry. The ~> 4.0 version constraint means "any 4.x version" so you get patches without breaking changes.
- The provider block configures how Terraform connects to Azure. The features {} block is required by the AzureRM provider (even if empty).
- The subscription_id tells Terraform which Azure subscription to deploy resources into. This is required in AzureRM provider 4.0 and above.
- In main.tf, find the line that says:
subscription_id = "your-subscription-id-here"
- Replace your-subscription-id-here with the subscription ID you noted from running az account show in Step 1, so the line looks like this:
subscription_id = "[[SUBSCRIPTION_ID=\"your Azure subscription ID\"]]"
Pro tip: find your subscription ID again
If you did not save your subscription ID, run az account show --query id -o tsv in your terminal. This prints just the ID with no extra formatting.
- Save main.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Seeing red squiggles in main.tf?
- Make sure every opening brace { has a matching closing brace }. HCL uses braces for every block.
- Check that your subscription ID is wrapped in double quotes ("..."). HCL strings must be quoted.
- Confirm the HashiCorp Terraform extension is installed. Without it, VS Code does not understand .tf syntax.
Help me fix syntax errors in my main.tf file
Initialize Terraform
Your configuration file tells Terraform what provider to use, but Terraform does not ship with any provider plugins built in. You need to run an initialization command so Terraform can download the AzureRM plugin.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, usually above Tab).
- Make sure the terminal is in your terraform-webserver folder. If you opened the folder in VS Code, it should already be there.
- Download the AzureRM provider plugin by running this command:
terraform init
What does terraform init do?
- It reads your required_providers block and downloads the matching provider plugin from the Terraform Registry.
- It creates a .terraform directory in your project folder to store the downloaded plugin.
- It generates a .terraform.lock.hcl lock file that pins the exact provider version. This ensures everyone on your team uses the same version.
- Check the last line of the terminal output. You should see the message: Terraform has been successfully initialized!
- Look at the Explorer sidebar in VS Code. You should see a new .terraform folder and a .terraform.lock.hcl file alongside your main.tf.
Seeing an error during terraform init?
- If you see Error: Failed to query available provider packages, check your internet connection. Terraform needs to download the provider from the registry.
- If you see a syntax error pointing to main.tf, double check your braces and quotes match the code above. A single missing brace will prevent initialization.
- If you see command not found: terraform, go back to Step 1 and verify Terraform is installed correctly with terraform --version.
Help me debug my terraform init error
Awesome, I've got everything!
Great. Double check you have saved main.tf and that your terminal shows the successful initialization message.
I'd like to double check the full code
Here is the complete main.tf so far. Make sure yours matches (with your own subscription ID in place of the placeholder).
# Declare the required provider and version
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
required_version = ">= 1.2"
}
# Configure the Azure provider with your subscription
provider "azurerm" {
features {}
subscription_id = "your-subscription-id-here"
}
Terraform is initialized and connected to Azure. Next up, you will define the network infrastructure that your web server will live inside.
Define the Network Infrastructure
Your Terraform project is initialized and connected to your Azure subscription. Now it's time to define actual cloud resources in your configuration.
Before a virtual machine can go online, it needs a network to live in. In this step, you'll build the networking foundation: a resource group to organize everything, a virtual network with a subnet, and a security group that controls which traffic can reach your server.
In this step, get ready to:
- Add a resource group, virtual network, and subnet to your configuration.
- Create a network security group with SSH and HTTP rules.
- Preview your infrastructure plan with terraform plan.
Add the resource group and virtual network
Every Azure deployment starts with a resource group. It acts as a container that holds all related resources for your project. Inside that group, you'll create a virtual network (VNet) and a subnet for your server to connect to.
- In main.tf, add the following code below your provider "azurerm" block:
# Create a resource group to hold all project resources
resource "azurerm_resource_group" "rg" {
name = "rg-terraform-webserver"
location = "eastus"
}
# Create a virtual network with a 10.0.0.0/16 address space
resource "azurerm_virtual_network" "vnet" {
name = "vnet-webserver"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
}
# Create a subnet inside the virtual network
resource "azurerm_subnet" "subnet" {
name = "snet-webserver"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = ["10.0.1.0/24"]
}
What does this code do?
- azurerm_resource_group creates a logical container named rg-terraform-webserver in the eastus region. All other resources in this project will live inside it.
- azurerm_virtual_network creates a private network with the address space 10.0.0.0/16. This gives you over 65,000 usable IP addresses.
- azurerm_subnet carves out a smaller 10.0.1.0/24 range (256 addresses) inside the VNet. Your VM will be placed in this subnet.
- Notice that the VNet and subnet reference the resource group using azurerm_resource_group.rg.name instead of hardcoding the name. Terraform uses these references to figure out the correct creation order automatically.
- Save main.tf.
You won't see any changes in Azure yet. The next substep adds the security group, and then you'll preview everything with terraform plan.
Seeing red squiggly lines in VS Code?
- Make sure every opening brace { has a matching closing brace }. A missing brace is the most common HCL syntax error.
- Check that string values like "rg-terraform-webserver" are wrapped in double quotes, but resource references like azurerm_resource_group.rg.name are not.
- Make sure the HashiCorp Terraform extension is installed in VS Code for proper syntax checking.
Still stuck? Ask for help.
Add the network security group
A Network Security Group (NSG) acts as a firewall for your subnet. Without one, no traffic can reach your server. You need two rules: one to allow SSH connections for managing the VM, and one to allow HTTP traffic so visitors can see your web page.
- In main.tf, add the following code below the azurerm_subnet resource you just created:
# Create a network security group with rules for SSH and HTTP access
resource "azurerm_network_security_group" "nsg" {
name = "nsg-webserver"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
# Allow SSH connections on port 22 (for testing only)
security_rule {
name = "AllowSSH"
priority = 1001
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
# Allow HTTP traffic on port 80
security_rule {
name = "AllowHTTP"
priority = 1002
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
# Associate the NSG with the 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
}
What does this code do?
- The first security_rule opens port 22 for SSH. This lets you connect to the VM's command line remotely.
- The second security_rule opens port 80 for HTTP. This is how web browsers will reach your Nginx server.
- The priority values (1001 and 1002) determine the order Azure evaluates rules. Lower numbers are evaluated first.
- The azurerm_subnet_network_security_group_association links the NSG to your subnet. Without this association, the security rules would exist but not protect anything.
Why is source_address_prefix set to * for SSH?
Setting source_address_prefix = "*" means SSH is open to the entire internet. This is fine for a short-lived test server that you'll destroy at the end of this project. In a production environment, you would restrict this to your own IP address.
- Save main.tf.
You won't see any changes in Azure yet. The next substep runs terraform plan to preview everything you've defined so far.
Getting a syntax error in the security rules?
- Each security_rule block is nested inside the azurerm_network_security_group resource, not outside it. Make sure both rule blocks sit between the NSG's opening and closing braces.
- Verify that every attribute name uses underscores (e.g., source_port_range), not hyphens. HCL attribute names never use hyphens.
Still stuck? Ask for help.
Run terraform plan to preview
Before creating anything in Azure, Terraform lets you preview exactly what it will do. The terraform plan command compares your configuration files against the current state and shows you a detailed execution plan.
- Preview your infrastructure by running this command in the terminal:
terraform plan
Terraform outputs a list of every resource it will create. You should see Plan: 5 to add, 0 to change, 0 to destroy at the bottom of the output.
What are the 5 resources?
The five resources Terraform plans to create are:
- azurerm_resource_group.rg
- azurerm_virtual_network.vnet
- azurerm_subnet.subnet
- azurerm_network_security_group.nsg
- azurerm_subnet_network_security_group_association.nsg_assoc
Nothing has been created in Azure yet. This is the "dry run" step. You'll run terraform apply later to make it real.
Seeing errors instead of the plan?
- If you see Error: Insufficient features blocks, make sure your provider "azurerm" block includes features {}.
- If you see an authentication error, run az login again. Your session may have expired since Step 1.
- If you see Error: Reference to undeclared resource, check that the resource names in your references match exactly. For example, azurerm_resource_group.rg must match the label "rg" in your resource block.
Still stuck? Ask for help.
Awesome, I've got everything!
Double check you've saved main.tf and your terraform plan shows 5 resources to add.
I'd like to double check the full code
Here is the full main.tf file so far, including the provider configuration from Step 2 and the networking resources from this step.
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
required_version = ">= 1.2"
}
provider "azurerm" {
features {}
subscription_id = "your-subscription-id-here"
}
resource "azurerm_resource_group" "rg" {
name = "rg-terraform-webserver"
location = "eastus"
}
resource "azurerm_virtual_network" "vnet" {
name = "vnet-webserver"
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 = "snet-webserver"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = ["10.0.1.0/24"]
}
resource "azurerm_network_security_group" "nsg" {
name = "nsg-webserver"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
security_rule {
name = "AllowSSH"
priority = 1001
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "AllowHTTP"
priority = 1002
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
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
}
Your networking foundation is solid. Next up, you'll add a public IP, a network interface, and the Linux virtual machine itself.
Define the Virtual Machine
In the previous step, you laid down the network foundation for your web server. You have a virtual network, subnet, and security group all defined in main.tf.
Now it is time to define the actual virtual machine that will run your web server. Before a VM can go live, it needs two more networking components: a public IP address so the internet can reach it, and a network interface that plugs the VM into your subnet.
In this step, get ready to:
- Add a public IP and network interface to your Terraform configuration.
- Define a Linux virtual machine with a cloud-init script that installs Nginx.
- Generate an SSH key pair for secure access to the VM.
Add the public IP address
A public IP address is what makes your web server reachable from the internet. Without one, your VM would only be accessible from within the Azure virtual network.
- In main.tf, add the following resource block below your azurerm_subnet_network_security_group_association resource:
resource "azurerm_public_ip" "pip" {
name = "pip-webserver"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
allocation_method = "Static"
sku = "Standard"
}
What does this code do?
- allocation_method = "Static" means the IP address stays the same even if the VM restarts. A Dynamic IP could change on each reboot, which would break bookmarks and DNS records.
- sku = "Standard" uses the Standard SKU, which is the current recommended tier for Azure public IPs.
- The location and resource_group_name reference your existing resource group. This is how Terraform knows all your resources belong together.
- Save main.tf.
You will not see a visible change yet. The next chunk adds the network interface that connects this public IP to your VM.
Add the network interface
A network interface is the glue between your VM and the network. It connects the VM to your subnet and attaches the public IP you just created.
- In main.tf, add the following resource block below the azurerm_public_ip resource you just added:
resource "azurerm_network_interface" "nic" {
name = "nic-webserver"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
# Connect the NIC to the subnet and attach the public IP
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.subnet.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.pip.id
}
}
What does this code do?
- The ip_configuration block ties three things together: the subnet (via subnet_id), a private IP (dynamically assigned), and your static public IP (via public_ip_address_id).
- Terraform automatically figures out that the NIC depends on the subnet and the public IP because of these references. It will create them in the correct order.
- Save main.tf.
You will not see a change yet. The network interface needs a VM to attach to, which you will define next.
Seeing red squiggles in VS Code?
- Make sure azurerm_subnet.subnet and azurerm_public_ip.pip match the resource names you defined earlier. The resource type and label must match exactly.
- Check that every opening brace { has a matching closing brace }. A missing brace in an earlier resource block can cause errors in later blocks.
- Help me debug my network interface resource.
Generate an SSH key pair (if needed)
Your VM configuration will use an SSH key for authentication instead of a password. Before writing the VM resource, you need to make sure you have a key pair on your machine.
- Check if you already have an SSH key by running this command:
ls ~/.ssh/id_rsa.pub
I see a file path
You already have an SSH key. You are ready to define the VM resource.
No such file or directory
- Generate a new SSH key pair by running this command:
ssh-keygen -t rsa -b 4096
- The command prompts you for a file location. Press Enter to accept the default (~/.ssh/id_rsa).
- When prompted for a passphrase, press Enter twice to skip setting one.
- Confirm the key was created by running:
ls ~/.ssh/id_rsa.pub
You should see the file path ~/.ssh/id_rsa.pub printed in the terminal.
Still seeing "No such file or directory"?
- Make sure you pressed Enter to accept the default file path when prompted. If you typed a custom path, the file will not be at ~/.ssh/id_rsa.pub.
- Help me generate an SSH key pair.
Why SSH keys instead of a password?
SSH keys are the recommended authentication method for Azure Linux VMs. They are more secure than passwords and can be managed programmatically. Your Terraform configuration will reference the public key file (id_rsa.pub) so Azure can accept connections from your machine.
Add the Linux virtual machine
This is the centerpiece of your configuration. The VM resource pulls together everything you have built so far: the network interface, the SSH key, and a cloud-init script that installs Nginx the moment the VM boots.
- In main.tf, add the following resource block below the azurerm_network_interface resource:
resource "azurerm_linux_virtual_machine" "vm" {
name = "vm-webserver"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
size = "Standard_B2ats_v2"
admin_username = "azureuser"
network_interface_ids = [
azurerm_network_interface.nic.id,
]
# Authenticate with your SSH public key
admin_ssh_key {
username = "azureuser"
public_key = file("~/.ssh/id_rsa.pub")
}
# Configure the VM's disk
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
# Use Ubuntu 22.04 LTS as the base image
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
# Install and start Nginx on first boot
custom_data = base64encode(<<-EOF
#!/bin/bash
sudo apt-get update -y
sudo apt-get install -y nginx
sudo systemctl start nginx
sudo systemctl enable nginx
echo "<html><body><h1>Hello from Terraform!</h1><p>This server was deployed using Infrastructure as Code.</p></body></html>" | sudo tee /var/www/html/index.html
EOF
)
}
What does this code do?
- size = "Standard_B2ats_v2" selects a B-series v2 VM. This size is eligible for Azure's free tier (750 hours/month for the first 12 months). If you are past the free tier window, it costs approximately $0.01/hour.
- file("~/.ssh/id_rsa.pub") reads your local SSH public key file and passes it to Azure. This is how the VM knows to trust your machine for SSH connections.
- The source_image_reference block picks Ubuntu 22.04 LTS as the operating system.
- The custom_data block is a cloud-init script that runs automatically on the VM's first boot. It installs Nginx, starts the service, and writes a custom HTML page.
Why custom_data instead of SSH-ing in manually?
You could SSH into the VM after it boots and install Nginx by hand. But that defeats the purpose of Infrastructure as Code. Using custom_data means every VM created from this config comes pre-configured with Nginx. No manual steps, no drift between machines.
- Save main.tf.
- Verify the configuration is valid by running this command in your terminal:
terraform plan
You should see a plan showing 7 resources to add (resource group, virtual network, subnet, NSG, NSG association, public IP, network interface, and VM). The output ends with Plan: 8 to add, 0 to change, 0 to destroy.
Seeing errors in the plan output?
- If you see Error: Invalid reference, check that all resource references (like azurerm_resource_group.rg.name) match the resource names you defined earlier in the file.
- If you see Error: no such file or directory for ~/.ssh/id_rsa.pub, go back to the SSH key generation step above and create your key pair.
- If you see a syntax error, check for missing closing braces } or missing equals signs in your resource blocks.
- Help me debug my terraform plan errors.
Awesome, I've got everything!
Double check you have saved main.tf and that your terraform plan runs without errors.
I'd like to double check the full code
Here is the complete main.tf file up to this point. Make sure your file matches this exactly.
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
required_version = ">= 1.2"
}
provider "azurerm" {
features {}
subscription_id = "your-subscription-id-here"
}
resource "azurerm_resource_group" "rg" {
name = "rg-terraform-webserver"
location = "eastus"
}
resource "azurerm_virtual_network" "vnet" {
name = "vnet-webserver"
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 = "snet-webserver"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = ["10.0.1.0/24"]
}
resource "azurerm_network_security_group" "nsg" {
name = "nsg-webserver"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
security_rule {
name = "AllowSSH"
priority = 1001
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "AllowHTTP"
priority = 1002
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
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 = "pip-webserver"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_interface" "nic" {
name = "nic-webserver"
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 = "vm-webserver"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
size = "Standard_B2ats_v2"
admin_username = "azureuser"
network_interface_ids = [
azurerm_network_interface.nic.id,
]
admin_ssh_key {
username = "azureuser"
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
sudo apt-get update -y
sudo apt-get install -y nginx
sudo systemctl start nginx
sudo systemctl enable nginx
echo "<html><body><h1>Hello from Terraform!</h1><p>This server was deployed using Infrastructure as Code.</p></body></html>" | sudo tee /var/www/html/index.html
EOF
)
}
output "public_ip_address" {
value = azurerm_public_ip.pip.ip_address
}
output "ssh_command" {
value = "ssh azureuser@${azurerm_public_ip.pip.ip_address}"
}
Your entire infrastructure is now defined in code. Next up, you will run terraform apply to bring it all to life and see your web server running in a browser.
Deploy and Verify Your Web Server
Your Terraform configuration is complete. Every resource your web server needs is defined in main.tf.
It's time to bring your infrastructure to life. In this step, you'll run terraform apply to provision everything on Azure, then verify your web server is running by visiting it in a browser and connecting over SSH.
In this step, get ready to:
- Apply your Terraform configuration to provision all Azure resources.
- Verify the Nginx web server is accessible in a browser.
- SSH into the VM to confirm Nginx is running.
Apply the Terraform configuration
You've already previewed your infrastructure with terraform plan. Now you'll run the command that actually creates everything on Azure.
- In your terminal, make sure you are inside the terraform-webserver folder.
- Deploy your infrastructure by running this command:
terraform apply
What happens when you run this?
Terraform first shows you an execution plan, just like terraform plan. It then pauses and asks you to confirm by typing yes. Nothing is created until you confirm.
- Review the execution plan. It should show 8 resources to add.
- Type yes and press Enter to confirm.
Azure takes about 2-3 minutes to provision all the resources. You'll see progress updates in the terminal as each resource is created.
When it finishes, you should see a message like Apply complete! Resources: 8 added, 0 changed, 0 destroyed.
Below that message, Terraform prints your outputs. You should see public_ip_address with an IP address and ssh_command with a ready-to-use SSH command.
Seeing an error during apply?
- If you see an authentication error, run az login again to refresh your Azure session.
- If you see a quota or capacity error for Standard_B2ats_v2, the VM size may not be available in eastus. Try changing the location in your resource group to westus2 or centralus.
- If you see an SSH key error, confirm the file ~/.ssh/id_rsa.pub exists by running ls ~/.ssh/id_rsa.pub in your terminal.
- Help me debug my terraform apply error.
Get the public IP and test the web server
Your infrastructure is live. The public_ip_address output from terraform apply is the address of your web server.
- Copy the public_ip_address value from the terminal output.
- Open a browser and navigate to the following URL, replacing the IP with your own:
http://[[PUBLIC_IP="your VM's public IP address"]]
You should see a web page with the heading Hello from Terraform! and a message that reads This server was deployed using Infrastructure as Code.
Where does this page come from?
The custom_data block in your VM resource contains a cloud-init script. When the VM first boots, cloud-init automatically installs Nginx, starts the web server, and writes a custom index.html file. You never had to SSH in and configure anything manually.
Page not loading?
- Make sure you are using http:// and not https://. Your NSG only allows traffic on port 80 (HTTP).
- If you see a connection timeout, cloud-init may still be running. Wait 1-2 minutes and refresh the page.
- If you see the default Nginx welcome page instead of Hello from Terraform!, cloud-init is still finishing. Wait another minute and refresh.
- My Nginx web page won't load in the browser.
SSH into the VM to verify
Seeing the page in a browser confirms traffic flows through your network stack. Connecting over SSH confirms you have direct access to the machine itself.
- Copy the ssh_command value from your terraform apply output, or run the following command with your public IP:
ssh azureuser@[[PUBLIC_IP="your VM's public IP address"]]
First-time SSH connection prompt
The first time you connect, SSH asks you to verify the host's fingerprint. Type yes and press Enter to continue. This is normal for any new SSH connection.
Once connected, your terminal prompt changes to azureuser@vm-webserver. You are now inside the Linux VM running on Azure.
- Confirm Nginx is running by running this command:
sudo systemctl status nginx
You should see active (running) in the output, highlighted in green. This confirms the web server is healthy.
- Type exit and press Enter to disconnect from the VM and return to your local terminal.
Can't connect via SSH?
- If you see Permission denied (publickey), the SSH key on your machine doesn't match the key Terraform configured on the VM. Confirm ~/.ssh/id_rsa.pub exists and is the same key referenced in your main.tf.
- If you see Connection timed out, your NSG may not have the SSH rule on port 22. Check the AllowSSH security rule in your azurerm_network_security_group resource.
- Help me troubleshoot my SSH connection to the Azure VM.
Pro tip: retrieve outputs anytime
If you need the public IP again later, you don't have to rerun terraform apply. Run terraform output from your project folder and Terraform prints all of your defined outputs from the current state.
Your web server is live and verified. You built, deployed, and confirmed a full Linux server on Azure without ever touching the portal. Next up, you'll tear down every resource with a single command to make sure nothing keeps running.
Tear Down All Resources
You deployed a web server to Azure and confirmed it's running Nginx. Now it's time to prove that Terraform can tear it all down just as easily.
Cloud resources cost money when left running. The terraform destroy command removes every resource Terraform created, bringing your bill back to zero.
In this step, get ready to:
- Destroy all Azure resources with terraform destroy.
- Verify the cleanup in the Azure Portal.
Destroy the infrastructure
The terraform destroy command is the reverse of terraform apply. It removes every resource Terraform manages from your cloud account.
- Tear down all your Azure resources by running this command from the terraform-webserver directory:
terraform destroy
Terraform displays a destruction plan listing every resource it will remove, then prompts you for confirmation.
- Review the plan to confirm all your resources are listed.
- Type yes and press Enter to confirm the destruction.
- Wait 1-2 minutes for Terraform to remove all resources from Azure.
What does terraform destroy do?
- terraform destroy reads the terraform.tfstate file to identify every resource it created.
- It calculates the correct deletion order based on dependencies. For example, the VM is removed before the network interface it uses.
- This is the final stage of the Terraform lifecycle: init, plan, apply, destroy.
You should see Destroy complete! Resources: 8 destroyed. in your terminal.
Seeing an error during destroy?
- Make sure you are running the command from the terraform-webserver directory where your .tf files and .terraform directory live.
- If you see authentication errors, run az login again to refresh your Azure session.
Why is my terraform destroy failing?
Verify cleanup in the Azure Portal
Verifying in the portal ensures no resources were left behind that could incur charges.
- Open your browser and navigate to the Azure Portal.
- Sign in with your Azure account.
- In the search bar at the top of the page, type rg-terraform-webserver.
- Confirm that no matching resource group appears in the search results.
If the search returns no results, all your resources have been successfully removed.
Why verify in the portal?
Terraform tracks resources in its state file. Verifying in the portal provides a second confirmation that everything is gone.
This habit is especially important in team environments where resources might be created outside of Terraform.
Secret mission
Add Variables and Outputs for Multi-Environment Configuration
Your Terraform configuration works, but every value is hardcoded. What happens when you need a staging environment? Refactor your code to use variables and outputs so you can spin up any environment from the same codebase.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The Azure VM costs approximately $0.01/hour if you are past Azure's 12-month free tier window, so destroying resources is recommended to avoid charges.
Cost warning
Leaving the Azure VM running will incur charges of approximately $0.01/hour (about $7/month). If you are within Azure's 12-month free tier, you get 750 free hours/month of Standard_B2ats_v2 VMs at no cost. If you are unsure whether your free tier is still active, the safest option is to run terraform destroy to remove all resources.
Resources you used:
- Azure Resource Group (rg-terraform-webserver).
- Azure Virtual Network, Subnet, and Network Security Group.
- Azure Public IP Address and Network Interface.
- Azure Linux Virtual Machine (vm-webserver) running Nginx.
- Local project files (main.tf, .terraform directory, terraform.tfstate).
Keep everything running
No action needed. Choose this if you are still actively experimenting with your web server or plan to continue building on top of this infrastructure.
- Your VM will remain running and accessible at its public IP address.
- Keep your terraform-webserver project directory intact. The terraform.tfstate file tracks your deployed resources.
- Monitor your Azure billing to avoid unexpected charges.
Pause - I'll come back to this later
Deallocate the VM to stop compute charges while keeping all your resources and configuration in place. You can restart it later without reprovisioning.
- Run this command to stop and deallocate the VM:
az vm deallocate --resource-group rg-terraform-webserver --name vm-webserver
- This stops compute charges. Storage and networking charges (a few cents/month) will still apply.
- Keep your terraform-webserver directory. Do not delete the terraform.tfstate file.
- When you are ready to resume, start the VM again with:
az vm start --resource-group rg-terraform-webserver --name vm-webserver
Delete - I don't want to use this again
Remove all Azure resources and delete your local project files. This stops all charges immediately.
- If you already ran terraform destroy in Step 6, your Azure resources are already gone. Skip to the last item below.
- If you have not run terraform destroy yet, open your terminal, navigate to the terraform-webserver directory, and run:
terraform destroy
- Type yes when prompted to confirm the destruction.
- Verify in the Azure Portal that the resource group rg-terraform-webserver no longer exists.
- Delete the local project directory if you no longer need the configuration files:
rm -rf ~/terraform-webserver
Mission accomplished
That's a Wrap!
You just provisioned a full Linux web server on Azure without clicking a single button in the portal. Every resource, from the virtual network to the VM running Nginx, was defined in code and managed through Terraform commands.
Here's what you accomplished:
- Written Terraform configuration files in HCL to define a complete Azure networking stack and Linux virtual machine.
- Deployed a live Nginx web server using terraform apply and verified it in a browser.
- Torn down all infrastructure with a single terraform destroy command, leaving zero resources behind in Azure.
- Refactored your configuration with variables and outputs to support multi-environment deployments from a single codebase.
Ready to quiz yourself?