Introduction
Terraform lets you write configuration files that create cloud resources. Instead of clicking through Azure's portal, you define what you want in code, and Terraform builds it.
This guide walks through deploying a Linux VM on Azure with proper networking and security. We'll start with basic resources, then reorganize everything into reusable modules that work across different projects.
What We'll Build
The Terraform code creates these Azure resources:
Resource Group - Container for organizing resources
Virtual Network (VNet) + Subnet - Private network for your resources
Network Security Group (NSG) - Firewall rules
Public IP Address - So you can connect from the internet
Network Interface (NIC) - Connects the VM to the network
Linux Virtual Machine - Ubuntu server with SSH access
Understanding the Variables
These variables let you customize the deployment without changing the main code:
Important: Setting my_ip_cidr to 0.0.0.0/0 lets anyone try to SSH in. Always use your specific IP with /32.
The Infrastructure Code Explained
- Resource Group
resource "azurerm_resource_group" "rg" {
name = "rg-terraform-demo"
location = var.location
}
The Resource Group is a container for all your Azure resources. Using var.location instead of hardcoding the region means you can deploy to different locations without editing the code.
Tip: Use naming like rg-${var.project}-${var.env} to separate dev, staging, and prod environments.
2. Virtual Network (VNet)
resource "azurerm_virtual_network" "vnet" {
name = "vnet-demo"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
}
The VNet defines your private IP address space. The 10.0.0.0/16 range gives you 65,536 IP addresses.
When you reference azurerm_resource_group.rg.location, Terraform knows it needs to create the Resource Group first. You don't have to specify the order—Terraform figures it out.
Tip: Pick an address space that won't overlap with other VNets you might connect to later. Use variables for address spaces when building reusable modules.
3. Subnet
resource "azurerm_subnet" "subnet" {
name = "subnet-demo"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = ["10.0.1.0/24"]
}
Subnets divide your VNet into smaller segments. The 10.0.1.0/24 prefix gives you 256 IP addresses.
Note: We're attaching the NSG to the NIC in this guide, but you can also attach NSGs at the subnet level to protect all resources in that subnet at once.
4. Network Security Group (NSG)
resource "azurerm_network_security_group" "nsg" {
name = "nsg-demo"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
}
The NSG acts as a firewall. By itself, it's just a container the actual rules come next.
5. NSG Rule: SSH Access
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 = var.my_ip_cidr
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.rg.name
network_security_group_name = azurerm_network_security_group.nsg.name
}
This rule allows SSH traffic (port 22) from your IP address.
What the fields mean:
priority (1001): Rules run from lowest to highest number (100-4096). Lower numbers go first.
direction: Inbound controls incoming traffic; Outbound controls outgoing.
source_address_prefix: var.my_ip_cidr restricts SSH to only your IP.
For production: Don't expose SSH publicly. Use Azure Bastion or a jumpbox instead.
6. Public IP Address
resource "azurerm_public_ip" "pip" {
name = "pip-demo"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
allocation_method = "Dynamic"
}
The Public IP lets you connect to your VM from the internet. Dynamic allocation assigns an IP when the resource starts; Static reserves a fixed IP.
Production note: Avoid public IPs in production. Use private IPs with VPN, ExpressRoute, or Azure Bastion. If you need a fixed IP for DNS or firewall rules, use allocation_method = "Static".
7. Network Interface (NIC)
resource "azurerm_network_interface" "nic" {
name = "nic-demo"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
ip_configuration {
name = "ipconfig"
subnet_id = azurerm_subnet.subnet.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.pip.id
}
network_security_group_id = azurerm_network_security_group.nsg.id
}
The NIC connects your VM to the network. This config:
Attaches to the subnet with subnet_id
Associates the public IP with public_ip_address_id
Applies NSG rules with network_security_group_id
The .id attribute returns the unique Azure resource identifier. Terraform uses these to figure out what order to create things.
8. Linux Virtual Machine
resource "azurerm_linux_virtual_machine" "vm" {
name = "vm-demo"
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_ssh_key {
username = var.admin_username
public_key = file(var.ssh_public_key_path)
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "UbuntuServer"
sku = "22_04-lts"
version = "latest"
}
}
Key parts:
admin_ssh_key: The file() function reads your SSH public key and sets up authentication. Use the absolute path like /home/you/.ssh/id_rsa.pub—Terraform doesn't expand ~.
os_disk: Standard_LRS is locally redundant storage. For better performance, use Premium_LRS or StandardSSD_LRS.
source_image_reference: This pulls Ubuntu 22.04 LTS from Canonical. version = "latest" always grabs the newest image.
Production tip: Pin the image version to a specific build or use managed images so you get the same version every time.
How Terraform Manages Dependencies
Terraform builds a dependency graph based on references in your code. When you reference azurerm_resource_group.rg.name or .id, Terraform knows that the resource needs to exist first.
Order of creation:
Resource Group → VNet → Subnet → Public IP & NSG → NIC → VM
You don't need to manually specify the order Terraform figures it out.
Configuration Files
variables.tf
variable "location" {
description = "Azure location"
type = string
default = "eastus"
}
variable "my_ip_cidr" {
description = "Your public IP or CIDR to allow SSH (e.g. 203.0.113.4/32)"
type = string
default = "0.0.0.0/0"
}
variable "vm_size" {
description = "Size of the VM"
type = string
default = "Standard_B1s"
}
variable "admin_username" {
description = "Username for admin user"
type = string
default = "azureuser"
}
variable "ssh_public_key_path" {
description = "Absolute path to SSH public key file"
type = string
default = "/home/you/.ssh/id_rsa.pub"
}
terraform.tfvars (Example)
location = "eastus"
my_ip_cidr = "203.0.113.4/32"
vm_size = "Standard_B1s"
admin_username = "azureuser"
ssh_public_key_path = "/home/you/.ssh/id_rsa.pub"
Security Warning: Never commit terraform.tfvars with real secrets to version control. Add it to .gitignore immediately.
Running Your Terraform Code
Execute these commands in your project directory:
# Initialize Terraform (downloads provider plugins)
terraform init
# Validate syntax and configuration
terraform validate
# Preview changes before applying
terraform plan -out=tfplan
# Apply the changes
terraform apply tfplan
# Or apply directly with auto-approval
terraform apply --auto-approve
Cleanup:
terraform destroy --auto-approve
Best Practices & Security
Security
1.Lock Down SSH Access: Set my_ip_cidr to** /32*. Run curl ifconfig.me* to find your public IP.
2.Use Azure Bastion: For production, use Azure Bastion instead of public IPs for secure, browser-based access.
3.Never Commit Secrets: Add terraform.tfvars and *.tfstate to .gitignore. Use remote state storage for teams.
Code Quality
4.Use Variables: Parameterize locations, sizes, names, and tags so you can reuse code.
5.Tag Everything: Add tags for cost tracking:
tags = {
owner = "you"
environment = var.env
project = "demo"
}
- Format and Validate: Run terraform fmt and terraform validate before committing. Set up pre-commit hooks.
Team Collaboration
- Remote State: Use Azure Storage backend with state locking to avoid conflicts.
- Least-Privilege Service Principals: For CI/CD, only grant necessary permissions (e.g., Resource Group Contributor, not Subscription Owner). 9.Use Modules: Split code into reusable modules (modules/network, modules/compute).
Operations
10.Avoid Provisioners: Use cloud-init or Ansible instead of Terraform's provisioner blocks.
11.Pin Provider Versions: Lock provider versions in your terraform block to prevent breaking changes.
12.Monitor Resources: Enable Azure Monitor and diagnostics for production workloads.
**
Next Steps: Building Production-Ready Modules
**
Here's how to reorganize this into a scalable setup.
Project Structure
Why Use This Structure
- Reusability: Use modules across dev, staging, and production
- Separation: Network and compute teams can work independently
- Testing: Test modules separately before combining them
- Versioning: Version and publish stable modules to a registry
VS Code Integration
With VS Code tasks, you can run Terraform commands with one click:
1.Open Command Palette (Ctrl+Shift+P)
2.Select "Tasks: Run Task"
3.Choose: Init, Validate, Plan, Apply, or Destroy
No more typing the same commands over and over.
Advanced Improvements
Once you're comfortable with the basics:
1.Add Outputs: Export public IPs and resource IDs for other modules or documentation.
2.Use Azure Key Vault: Store secrets securely and access them with managed identities.
3.Enable Monitoring: Deploy Azure Monitor agents and set up Log Analytics.
4.Multiple Environments: Use Terraform workspaces or separate tfvars files for dev/staging/prod.
5.Set Up CI/CD: Run terraform plan on pull requests and terraform apply after approval.
6.Implement Azure Bastion: Remove all public IPs and direct SSH access.
**
Conclusion
You now have the basics for managing Azure infrastructure with Terraform. This guide covered networking, security, and compute resources, organized into a maintainable structure.
Key takeaways:
Use variables to make code reusable
Lock down security by restricting IP access
Use modules for better organization
Never commit secrets to version control
Use remote state for teams
The modular setup will scale as you add more resources and environments. Start simple, automate early, and improve as you go.
Ready to deploy? Update terraform.tfvars with your values and run terraform apply.


Top comments (0)