Azure Terraform - Create a virtual machine and enable SSH and ping

Introduction
Explanation of the terraform yaml file
Previewing, applying infrastructure changes with terraform
Connecting to the virtual machine via ssh and using ping
Deleting created infrastructure
The reference .tf Terraform configuration file

Introduction

In this tutorial we will deploy a Linux VM to Azure cloud, using terraform.
The virtual machine will have a private and public IP, and we will enable remote login via ssh with a public/private keypair. We will also enable ping.
The first step is to create the .tf terraform file, which is yaml syntax. There should be a single .tf file in the working directory.

Explanation of the terraform yaml file

Let’s walk through the content of the file, which is at the end of this article.
The AzureRM Terraform Provider is called by the initialisation sequence; it allows managing resources within Azure Resource Manager. Its module is hashicorp/azurerm.
We will then, within the location group, choose uksouth as the deployment location.
The resource group defined in the block resource_group_name will be named debian-vm-rg. An Azure Resource Groupis a logical container that holds related Azure resources (VMs, storage accounts, databases, and networking components).
To log into the Linux machine, we will define admin_username with the account name of azureuser.
We will use a ssh keypair to log into the VM; we will generate it via the ssh-keygen command. It is best to specify the type as rsa, as this is the only type accepted by Azure for now. You can add a passphrase to the key, it will strengthen its security.

george@fedora:~$ ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa
Generating public/private rsa key pair.
Enter passphrase for "/home/george/.ssh/id_rsa" (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/george/.ssh/id_rsa1
Your public key has been saved in /home/george/.ssh/id_rsa1.pub
The key fingerprint is:
SHA256:bXuY4jPIELXEv/cwd4JEXgZoXFZXWtVqs06/xxx george@host
The key's randomart image is:
+---[RSA 4096]----+
|     . . o+o. ..O|
|      + +.. o. +E|
|     o + o o  ...|
|    . . ..o   +. |
|     .  Soo. ..o.|
|    .   ..=+o.+.o|
|     o ...+=.=.oo|
|      o.o. .. ..+|
|        .o     .o|
+----[SHA256]-----+

The vm_size variable will instantiate a virtual machine of the size we specify. Standard_B1s. It is a low-cost burstable VM size in Azure, ideal for workloads that don’t require sustained CPU usage but need occasional performance boosts.
It has:

  • 1 vCPU
  • 1 GiB RAM
  • Burstable CPU performance (extra power when needed)
  • Suitable for testing, lightweight workloads, and small web apps
  • Cost-effective with lower pricing than standard VM sizes

We will then specify the Azure Virtual Network resource called azurerm_virtual_network. Azure Virtual Network (VNet) is Microsoft Azure’s networking service that allows you to create private networks for your resources. It enables secure communication between Azure services, virtual machines, and even on-premises infrastructure.
It provides:

  • Isolated Networks – Each VNet is logically isolated, ensuring secure communication.
  • Custom Subnets – You can divide your VNet into subnets for better organisation.
  • Network Security Groups (NSG) – Control inbound and outbound traffic at the subnet or VM level. The IP address space for it is 10.0.0.0/16, meaning we can have $2^{16}$=65535 IP addresses. The network mask is /16, meaning that we can use the last two full bytes only x, y - 10.0.x.y.

Within the azurerm_subnet VNet we will create a subnet called azurerm_subnet, with an IP range of 10.0.2.0/24.
We can have 254 host IPs allocated to VMs, as per below, since the subnet mask is /24, meaning we have 8 bits ($2^{8}$=256) available to allocate IPs. The first (.0) and the last IP (.255) are reserved for the network ID and for broadcast to the entire subnet.
We can use ipcalc to calculate how many usable host IPs there are in a subnet:

george@fedora:~$ ipcalc 10.0.2.0/24
Network:        10.0.2.0/24
Netmask:        255.255.255.0 = 24
Broadcast:      10.0.2.255

Address space:  Private Use
HostMin:        10.0.2.1
HostMax:        10.0.2.254
Hosts/Net:      254

A private IP address will be allocated from the IP range of 10.0.2.0/24.
The resource azurerm_public_ip will allocate a public IP address, so that we can connect to the machine via internet; the private IP address cannot be accessed publicly. The public IP will be static and associated with a hostname, will not change for the lifetime of the VM. This helps in production, to properly access resources such as email/database servers. A dynamic IP changes if a server is stopped and started after maintenance, and this would disrupt other computers accessing our machine, who expect the same IP/hostname pair.
We will specify the azurerm_network_security_group NSG. This network security group will have two inbound rules (from machines from the internet) to allow:

  • inbound ping to the VM (useful to know if the machine is up)
  • ssh connection to the machine via port 22.
    We will specify azurerm_network_interface as a network card, whose ip_configuration section specifies the use of dynamic private IP.
    The azurerm_linux_virtual_machine resource will instantiate the VM with the size, in the location and with the user account we specified earlier.
    We will be using debian-12 as the operating system.
    The os_disk resource will specify a disk with the size of 30 GB.
    The Standard_LRS disk type refers to Standard Locally Redundant Storage (LRS) where your data is stored three times within a single Azure data centre.
    The admin_ssh_key variable will use the ssh key we specified earlier.
    The last two code blocks will output the self-explanatory variables public_ip_address and admin_username.

Previewing, applying infrastructure changes with terraform

To create all the resources specified in the terraform file, we will run three commands.
The first, terraform init, initialises the working directory containing Terraform configurations:

  • downloads provider plugins – (Azure, AWS, or Google Cloud) based on the .tf configuration file.
  • sets up backend configuration – if using remote state storage (e.g., Azure Storage, AWS S3), it configures how Terraform will store state files.
  • prepares the workspace – it creates a .terraform directory where Terraform maintains metadata about plugins, modules, and the state backend.
  • validates the configuration – ensures all required dependencies are correctly installed before moving forward.

The next command, terraform plan, is used to preview the changes Terraform will apply to the infrastructure before actually making them. It helps you understand what will change without modifying any resources.
The command terraform apply executes the infrastructure changes that were previewed in terraform plan. It creates, updates, or destroys resources based on the Terraform configuration files.
To skip the confirmation prompt, use:

terraform apply -auto-approve

Here is the output of this command:

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # azurerm_linux_virtual_machine.main will be created
  + resource "azurerm_linux_virtual_machine" "main" {
      + admin_username                                         = "azureuser"
      + allow_extension_operations                             = true
      + bypass_platform_safety_checks_on_user_schedule_enabled = false
      + computer_name                                          = (known after apply)
      + disable_password_authentication                        = true
      + disk_controller_type                                   = (known after apply)
      + extensions_time_budget                                 = "PT1H30M"
[..]
      + admin_ssh_key {
          + public_key = "ssh-rsa xxx= george@host"
          + username   = "azureuser"
        }

      + os_disk {
          + caching                   = "ReadWrite"
          + disk_size_gb              = 30
          + name                      = (known after apply)
          + storage_account_type      = "Standard_LRS"
          + write_accelerator_enabled = false
        }

      + source_image_reference {
          + offer     = "debian-12"
          + publisher = "Debian"
          + sku       = "12-gen2"
          + version   = "latest"
        }
[..]
  # azurerm_public_ip.main will be created
  + resource "azurerm_public_ip" "main" {
      + allocation_method       = "Static"
      + ddos_protection_mode    = "VirtualNetworkInherited"
      + fqdn                    = (known after apply)
      + id                      = (known after apply)
      + idle_timeout_in_minutes = 4
      + ip_address              = (known after apply)
      + ip_version              = "IPv4"
      + location                = "uksouth"
      + name                    = "debian12-vm-pip"
      + resource_group_name     = "debian-vm-rg"
      + sku                     = "Standard"
      + sku_tier                = "Regional"
    }

When terraform apply completes successfully, it will output the user name and public IP that we can use to connect to the machine via ssh:

Apply complete! Resources: 8 added, 0 changed, 0 destroyed.

Outputs:

admin_username = "azureuser"
public_ip_address = "x.y.z.t"

Connecting to the virtual machine via ssh and using ping

Let’s connect via ssh, with the id_rsa keypair we created earlier:

ssh -i .ssh/id_rsa azureuser@x.y.z.t

We can also see if the VM is up, by using ping:

$ ping x.y.z.t
PING x.y.z.t (x.y.z.t) 56(84) bytes of data.
64 bytes from x.y.z.t: icmp_seq=1 ttl=47 time=40.0 ms

Deleting created infrastructure

To remove all resources we have created, we run terraform destroy:

azurerm_resource_group.main: Refreshing state... [id=/subscriptions/xxx-c84d-459e-bc17-xx/resourceGroups/debian-vm-rg]
azurerm_virtual_network.main: Refreshing state... [id=/subscriptions/xx-c84d-459e-bc17-xx/resourceGroups/debian-vm-rg/providers/Microsoft.Network/virtualNetworks/debian12-vm-vnet]
[..]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  - destroy

Terraform will perform the following actions:

  # azurerm_linux_virtual_machine.main will be destroyed
  - resource "azurerm_linux_virtual_machine" "main" {
      - admin_username                                         = "azureuser" -> null
      - allow_extension_operations                             = true -> null
      - bypass_platform_safety_checks_on_user_schedule_enabled = false -> null
      - computer_name                                          = "debian12-vm" -> null
      - disable_password_authentication                        = true -> null
      - disk_controller_type                                   = "SCSI" -> null
      - encryption_at_host_enabled                             = false -> null
      - extensions_time_budget                                 = "PT1H30M" -> null
      - id                                                     = "/subscriptions/xx-c84d-459e-bc17-xx/resourceGroups/debian-vm-rg/providers/Microsoft.Compute/virtualMachines/debian12-vm" -> null
      - location                                               = "uksouth" -> null
      - max_bid_price                                          = -1 -> null
      - name                                                   = "debian12-vm" -> null
      - network_interface_ids                                  = [
          - "/subscriptions/xx-c84d-459e-bc17-xx/resourceGroups/debian-vm-rg/providers/Microsoft.Network/networkInterfaces/debian12-vm-nic",
        ] -> null
      - patch_assessment_mode                                  = "ImageDefault" -> null
      - patch_mode                                             = "ImageDefault" -> null
      - platform_fault_domain                                  = -1 -> null
      - priority                                               = "Regular" -> null
      - private_ip_address                                     = "10.0.2.4" -> null
      - private_ip_addresses                                   = [
          - "10.0.2.4",
        ] -> null
      - provision_vm_agent                                     = true -> null
      - public_ip_address                                      = "x.y.z.t" -> null
      - public_ip_addresses                                    = [
          - "x.y.z.t",
        ] -> null
      - resource_group_name                                    = "debian-vm-rg" -> null
      - secure_boot_enabled                                    = false -> null
      - size                                                   = "Standard_B1s" -> null
      - tags                                                   = {} -> null
      - virtual_machine_id                                     = "xxx-8bd8-45e3-a2ca-851381933651" -> null
      - vm_agent_platform_updates_enabled                      = false -> null
      - vtpm_enabled                                           = false -> null
        # (13 unchanged attributes hidden)

      - admin_ssh_key {
          - public_key = "ssh-rsa xxx= george@host" -> null
          - username   = "azureuser" -> null
        }

      - os_disk {
          - caching                          = "ReadWrite" -> null
          - disk_size_gb                     = 30 -> null
          - name                             = "debian12-vm_OsDisk_1_6d9d5c0e7d574xxx" -> null
          - storage_account_type             = "Standard_LRS" -> null
          - write_accelerator_enabled        = false -> null
            # (3 unchanged attributes hidden)
        }

      - source_image_reference {
          - offer     = "debian-12" -> null
          - publisher = "Debian" -> null
          - sku       = "12-gen2" -> null
          - version   = "latest" -> null
        }
    }

[..]

We will need to confirm with yes:

Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes

azurerm_network_interface_security_group_association.main: Destroying... [id=/subscriptions/xx-c84d-459e-bc17-xx/resourceGroups/debian-vm-rg/providers/Microsoft.Network/networkInterfaces/debian12-vm-nic|/subscriptions/xx-c84d-459e-bc17-xx/resourceGroups/debian-vm-rg/providers/Microsoft.Network/networkSecurityGroups/debian12-vm-nsg]
azurerm_linux_virtual_machine.main: Destroying... [id=/subscriptions/xx-c84d-459e-bc17-xx/resourceGroups/debian-vm-rg/providers/Microsoft.Compute/virtualMachines/debian12-vm]
azurerm_network_interface_security_group_association.main: Destruction complete after 2s
azurerm_network_security_group.main: Destroying... [id=/subscriptions/xx-c84d-459e-bc17-xx/resourceGroups/debian-vm-rg/providers/Microsoft.Network/networkSecurityGroups/debian12-vm-nsg]
azurerm_linux_virtual_machine.main: Still destroying... [id=/subscriptions/xx-c84d-459e-bc17-...ft.Compute/virtualMachines/debian12-vm, 10s elapsed]
azurerm_resource_group.main: Destruction complete after 16s

Destroy complete! Resources: 8 destroyed.

The reference .tf Terraform configuration file

The complete yaml file is below, save it as main.tf or similar.

# Configure the AzureRM Provider
terraform {
  required_providers {
    azurerm = {
      source = "hashicorp/azurerm"
      # Using ~> 3.0 as it seemed to work with your environment and lock file after upgrade
      version = "~> 3.0"
    }
  }

  required_version = ">= 1.0.0"
}

provider "azurerm" {
  features {}
}

# --- Variables ---

variable "location" {
  description = "Azure region for deployment"
  type        = string
  default     = "uksouth" # Defaulting to UK South
}

variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
  default     = "debian-vm-rg" # Default resource group name
}

variable "vm_name" {
  description = "Name of the Virtual Machine"
  type        = string
  default     = "debian12-vm" # Default VM name
}

variable "admin_username" {
  description = "Username for the VM administrator account"
  type        = string
  default     = "azureuser" # Default admin username
}

variable "ssh_public_key" {
  description = "SSH public key to use for VM authentication"
  type        = string
  # Setting the default value directly in the file using your specific key
  default     = "ssh-rsa xxx= george@host"
}

variable "vm_size" {
  description = "Size of the Virtual Machine"
  type        = string
  default     = "Standard_B1s" # Smallest instance size
}

# --- Resources ---

# Create a Resource Group
resource "azurerm_resource_group" "main" {
  name     = var.resource_group_name
  location = var.location
}

# Create a Virtual Network
resource "azurerm_virtual_network" "main" {
  name                = "${var.vm_name}-vnet"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.0.0.0/16"]
}

# Create a Subnet
resource "azurerm_subnet" "internal" {
  name                 = "internal"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.2.0/24"]
}

# Create a Public IP Address
resource "azurerm_public_ip" "main" {
  name                = "${var.vm_name}-pip"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  allocation_method   = "Static"
  sku                 = "Standard" # Standard SKU is recommended
}

# Create a Network Security Group (NSG)
resource "azurerm_network_security_group" "main" {
  name                = "${var.vm_name}-nsg"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  # Allow SSH (TCP port 22)
  security_rule {
    name                       = "AllowSSH"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "Internet" # Consider restricting this to specific IPs
    destination_address_prefix = "*"
  }

  # Allow Ping (ICMP)
  security_rule {
    name                       = "AllowPing"
    priority                   = 110 # Must have a different priority
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Icmp"
    source_port_range          = "*"
    destination_port_range     = "*" # ICMP doesn't use destination ports
    source_address_prefix      = "Internet" # Consider restricting this to specific IPs
    destination_address_prefix = "*"
  }
}

# Create a Network Interface (NIC)
resource "azurerm_network_interface" "main" {
  name                = "${var.vm_name}-nic"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

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

  # NSG Association is done using a separate resource (see below)
}

# Associate the Network Interface with the Network Security Group
resource "azurerm_network_interface_security_group_association" "main" {
  network_interface_id      = azurerm_network_interface.main.id
  network_security_group_id = azurerm_network_security_group.main.id
}

# Create the Linux Virtual Machine
resource "azurerm_linux_virtual_machine" "main" {
  name                = var.vm_name
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  size                = var.vm_size # Use the variable VM size (Standard_B1s)
  admin_username      = var.admin_username
  network_interface_ids = [azurerm_network_interface.main.id] # Link the VM to the NIC

  # Configure the OS image - Debian 12 (Bookworm)
  source_image_reference {
    publisher = "Debian"
    offer     = "debian-12"
    sku       = "12-gen2" # Use the Gen2 SKU (ensure VM size supports Gen2)
    version   = "latest"
  }

  # Configure the OS disk
  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS" # Cheapest storage option
    disk_size_gb         = 30             # 30 GB disk size
  }

  # Configure SSH access using the public key - Relying on implicit injection by Azure/VMAccess during creation
  admin_ssh_key {
    username   = var.admin_username
    public_key = var.ssh_public_key # Use the variable SSH public key
  }

  # Optional: Enable password authentication (less secure - use SSH keys instead)
  # disable_password_authentication = false

  # Optional: Boot Diagnostics (useful for troubleshooting boot issues)
  # boot_diagnostics {
  #   storage_account_uri = "YOUR_STORAGE_ACCOUNT_BLOB_ENDPOINT" # Requires a storage account
  # }
}

# --- Removed the explicit VM Access Extension resource and the data block ---
# They are not included in this version to avoid the "Invalid data source" error.


# --- Outputs ---

# Output the Public IP address
output "public_ip_address" {
  description = "The public IP address of the Virtual Machine"
  value       = azurerm_public_ip.main.ip_address
}

# Output the Admin Username
output "admin_username" {
  description = "The admin username for the Virtual Machine"
  value       = azurerm_linux_virtual_machine.main.admin_username
}