Skip to main content

Terraform - Microsoft Azure: Deploy Server with SSH Key and custom Security Group; Dynamically Scale VM Deployment (Deploy 'n' number of VMs)

1838 words·
Terraform Azure Azure CLI
Terraform - This article is part of a series.
Part 3: This Article

Azure CLI
#

Install Azure CLI (Linux)
#

# Install Azure CLI
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash

Login to Azure
#

# Login to Azure: Desktop version
az login
# Login to Azure: Server version (Device Code Authentication)
az login --use-device-code

# Shell output: (Open URL in Browser and pose the code)
To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code C2763DNGS to authenticate.

Create Service Principal
#

Find your Azure subscription ID: https://portal.azure.com/#view/Microsoft_Azure_Billing/SubscriptionsBladeV2

# Create a Service Principal: Syntax
az ad sp create-for-rbac --name "terraform-playground" --role contributor --scopes /subscriptions/SUBSCRIPTION_ID

# Create a Service Principal: Example
az ad sp create-for-rbac --name "terraform-playground" --role contributor --scopes /subscriptions/12345678-1234-some-id...
# Shell output:
Creating 'contributor' role assignment under scope '/subscriptions/12345678-1234-some-id...
'
The output includes credentials that you must protect. Be sure that you do not include these credentials in your code or check the credentials into your source control. For more information, see https://aka.ms/azadsp-cli
{
  "appId": "5a56c6d8-some-app-id...",
  "displayName": "terraform-playground",
  "password": "bMF8Q~some-password...",
  "tenant": "30d87815-some-tenant"
}
  • --name "terraform-playground" service principal name

  • --role contributor” The Contributor role has permissions to create and manage all types of Azure resources but does not have permission to grant access to others.

  • --scopes /subscriptions/SUBSCRIPTION_ID” Defines the scope at which the role assignment is applied.


Set Environment Variables
#

Set the service principal authentication details as environment variable so that Terraform can access Azure:

# Set environment variables: Bash
export ARM_CLIENT_ID="5a56c6d8-some-app-id..."
export ARM_CLIENT_SECRET="bMF8Q~some-password..."
export ARM_SUBSCRIPTION_ID="12345678-1234-some-id..."
export ARM_TENANT_ID="30d87815-some-tenant"

# Set environment variables: PowerShell
$Env:ARM_CLIENT_ID = "5a56c6d8-some-app-id..."
$Env:ARM_CLIENT_SECRET = "bMF8Q~some-password..."
$Env:ARM_SUBSCRIPTION_ID = "12345678-1234-some-id..."
$Env:ARM_TENANT_ID = "30d87815-some-tenant"

Terraform Prerequisites
#

Install Terraform
#

Use the following script to install Terraform ob Debian based Linux distributions:

# Install the HashiCorp GPG key
wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor | sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null

# Verify the GPG key fingerprint
gpg --no-default-keyring --keyring /usr/share/keyrings/hashicorp-archive-keyring.gpg --fingerprint

# Add the official HashiCorp repository 
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

# Install Terraform
sudo apt update && sudo apt-get install terraform

Verify the installation:

# Verify the installation / check version
terraform version



Example: Deploy VM with SG
#

Overview
#

This Terraform example configuration creates the following Azure resources:

  • Resource Group: “terraform-playground”
  • Virtual Network: “vnet” “10.0.0.0/16”
  • Subnet: “vnet-subnet-1” “10.0.10.0/24"”
  • Security Group: “security-group-1” allow SSH, HTTP, HTTPS
  • Network Interface: “vm-1-nic”
  • Public IP “vm-1-publicip”
  • Virtual Machine: “vm-1” based on Ubuntu 22.04

GitHub repository: https://github.com/jueklu/terraform-azure-server-example

The file structure looks as follows:

├── main.tf
├── network.tf
├── outputs.tf
├── provider.tf
├── resource-group.tf
├── security-group.tf

Terraform Project
#

Project Folder & Azure Provider
#

Use the following script to create a Terraform project folder and the necessary provider configuration for Azure:

TF_PROJECT_NAME=terraform-azure-playground

mkdir $TF_PROJECT_NAME

cat << EOF >> "$TF_PROJECT_NAME/provider.tf"
# Terraform Provider
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "=3.0.2"
    }
  }
}

# Configure the Microsoft Azure Provider
provider "azurerm" {
  features {}
}
EOF

Azure Resource Group
#

  • resource-group.tf
# Resource Group
resource "azurerm_resource_group" "rg" {
  name     = "terraform-playground" # Define the resource group name
  location = "West Europe" # Define Azure region
}

Network
#

  • network.tf
# Virtual Network
resource "azurerm_virtual_network" "vnet" {
  name                = "vnet"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
}

# Subnet
resource "azurerm_subnet" "subnet-1" {
  name                 = "vnet-subnet-1"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["10.0.10.0/24"]
}


# Public IP
resource "azurerm_public_ip" "vm_public_ip" {
  name                = "vm-1-publicip"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  allocation_method   = "Dynamic" # Define static or dynamic
  sku                 = "Basic"
}

# Network Interface
resource "azurerm_network_interface" "nic-1" {
  name                = "vm-1-nic"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

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

Optional: Define a static private IP address for the network interface

# Network Interface
resource "azurerm_network_interface" "nic-1" {
  name                = "vm-1-nic"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.subnet-1.id
    private_ip_address_allocation = "Static"
    private_ip_address            = "10.0.10.5" # The first addresses in the subnet range are blocked
    public_ip_address_id          = azurerm_public_ip.vm_public_ip.id
  }
}

Security Group
#

  • security-group.tf
# Network Security Group and Rules
resource "azurerm_network_security_group" "sg-1" {
  name                = "security-group-1"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

# Allow SSH
  security_rule {
    name                       = "SSH"
    priority                   = 1001
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  # Allow HTTP
  security_rule {
    name                       = "HTTP"
    priority                   = 1002
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "80"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  # Allow HTTPS
  security_rule {
    name                       = "HTTPS"
    priority                   = 1003
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "443"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}


# Connect Security Group to Network Interface
resource "azurerm_network_interface_security_group_association" "nic-1-sg-1" {
  network_interface_id      = azurerm_network_interface.nic-1.id
  network_security_group_id = azurerm_network_security_group.sg-1.id
}

SSH Key
#

# Create a SSH key pair to access the Azure VM
ssh-keygen -t rsa -b 4096 -f ~/.ssh/tf-azure

Virtual Machine
#

Note: Azure disallows command user names like “admin”.

  • main.tf
# Virtual Machine
resource "azurerm_linux_virtual_machine" "vm-1" {
  name                = "vm-1"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  size                = "Standard_F2"
  admin_username      = "tfadmin"
  network_interface_ids = [
    azurerm_network_interface.nic-1.id,
  ]

  admin_ssh_key {
    username   = "tfadmin"
    public_key = file("/home/ubuntu/.ssh/tf-azure.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"
    version   = "latest"
  }
}

Outputs
#

  • outputs.tf
# Resource Group ID
output "resource_group_id" {
  value = azurerm_resource_group.rg.id
}

# VM-1 Public IP
output "vm_public_ip" {
  value = azurerm_public_ip.vm_public_ip.ip_address
  description = "The public IP address of the virtual machine vm-1"
}

# VM-1 Private IP
output "vm_private_ip" {
  value       = azurerm_network_interface.nic-1.ip_configuration[0].private_ip_address
  description = "The private IP address of the virtual machine vm-1"
}

Initialize Project & Deploy Resources
#

# Initialize the Terraform project
terraform init

# Validate the configuration files
terraform validate
# Apply the configuration
terraform apply

# Apply the configuration: Skip approval
terraform apply -auto-approve

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

Outputs:

resource_group_id = "/subscriptions/12345678-1234-some-id.../resourceGroups/terraform-playground"
vm_private_ip = "10.0.10.4"
vm_public_ip = ""

Output VM IP
#

# Refresh the Terraform state to get the VM public IP
terraform refresh

# Shell output:
resource_group_id = "/subscriptions/12345678-1234-some-id.../resourceGroups/terraform-playground"
vm_private_ip = "10.0.10.4"
vm_public_ip = "13.80.158.150"
# Manually list the VM IP addresses (after the refresh)
terraform output vm_public_ip
terraform output vm_private_ip

# Shell output:
"13.80.158.150"
"10.0.10.4"

Verify Deployment Details
#

The resource group should now be available in the Azure webinterface: https://portal.azure.com/#browse/resourcegroups

# List the resource group details: Azure CLI
az group show --name "terraform-playground"

# List resources in resource group
az resource list --resource-group "terraform-playground"

# List resources in resource group: Only name and type
az resource list --resource-group "terraform-playground" --query "[].{Name:name, Type:type}"

# Shell output:
[
  {
    "Name": "vnet",
    "Type": "Microsoft.Network/virtualNetworks"
  },
  {
    "Name": "vm-1-publicip",
    "Type": "Microsoft.Network/publicIPAddresses"
  },
  {
    "Name": "security-group-1",
    "Type": "Microsoft.Network/networkSecurityGroups"
  },
  {
    "Name": "vm-1-nic",
    "Type": "Microsoft.Network/networkInterfaces"
  },
  {
    "Name": "vm-1",
    "Type": "Microsoft.Compute/virtualMachines"
  },
  {
    "Name": "vm-1_OsDisk_1_6890be87dbaf4e969005f5ba19622653",
    "Type": "Microsoft.Compute/disks"
  }
]
# List deployment details
terraform show

Access the VM
#

# SSH into vm-1
ssh -i ~/.ssh/tf-azure tfadmin@13.80.158.150

Delete Resources
#

# Delete resources: Ask for confirmation
terraform destroy

# Delete resources: Auto approve
terraform destroy -auto-approve



Example: Dynamically Scale VM Deployment
#

Overview
#

This Terraform example uses the “provider.tf” and “resource-group.tf” configurations from the previous example. It dynamically deployes VMs and their dependencies based on a defined number.

GitHub repository: https://github.com/jueklu/terraform-azure-server-scaling-example


Terraform Project
#

Network
#

  • network.tf
# Virtual Network Resource
resource "azurerm_virtual_network" "vnet" {
  name                = "vnet"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
}

# Subnet
resource "azurerm_subnet" "subnet-1" {
  name                 = "vnet-subnet-1"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["10.0.10.0/24"]
}


# Public IPs
resource "azurerm_public_ip" "vm_public_ip" {
  count               = 3 # Define number of VMs
  name                = "vm-${count.index + 1}-publicip"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  allocation_method   = "Dynamic"
  sku                 = "Basic"
}

# Network Interface
resource "azurerm_network_interface" "nic-1" {
  count               = 3 # Define number of VMs
  name                = "vm-${count.index + 1}-nic"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.subnet-1.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.vm_public_ip[count.index].id
  }
}

Security Group
#

  • security-group.tf
# Network Security Group and Rules
resource "azurerm_network_security_group" "sg-1" {
  name                = "security-group-1"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

# Allow SSH
  security_rule {
    name                       = "SSH"
    priority                   = 1001
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  # Allow HTTP
  security_rule {
    name                       = "HTTP"
    priority                   = 1002
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "80"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  # Allow HTTPS
  security_rule {
    name                       = "HTTPS"
    priority                   = 1003
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "443"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}


# Connect Security Group to Network Interface
resource "azurerm_network_interface_security_group_association" "nic-1-sg-1" {
  count                 = 3 # Define number of VMs
  network_interface_id  = azurerm_network_interface.nic-1[count.index].id
  network_security_group_id = azurerm_network_security_group.sg-1.id
}

Virtual Machine
#

Note: Azure disallows command user names like “admin”.

  • main.tf
# Virtual Machine
resource "azurerm_linux_virtual_machine" "vm-1" {
  count                 = 3 # Define the VM count
  name                  = "vm-${count.index + 1}"
  resource_group_name   = azurerm_resource_group.rg.name
  location              = azurerm_resource_group.rg.location
  size                  = "Standard_F2"
  admin_username        = "tfadmin"
  network_interface_ids = [azurerm_network_interface.nic-1[count.index].id]

  admin_ssh_key {
    username   = "tfadmin"
    public_key = file("/home/ubuntu/.ssh/tf-azure.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"
    version   = "latest"
  }
}

Outputs
#

  • outputs.tf
# Resource Group ID
output "resource_group_id" {
  value = azurerm_resource_group.rg.id
}

# Output Public IPs of all VMs
output "vm_public_ips" {
  value = { for i in range(length(azurerm_public_ip.vm_public_ip)) : "VM-${i + 1}" => azurerm_public_ip.vm_public_ip[i].ip_address }
  description = "The public IP addresses of all virtual machines, mapped by VM identifier."
}

# Output Private IPs of all VMs
output "vm_private_ips" {
  value = { for i in range(length(azurerm_network_interface.nic-1)) : "VM-${i + 1}" => azurerm_network_interface.nic-1[i].ip_configuration[0].private_ip_address }
  description = "The private IP addresses of all virtual machines, mapped by VM identifier."
}

Initialize Project & Deploy Resources
#

# Initialize the Terraform project
terraform init

# Validate the configuration files
terraform validate
# Apply the configuration
terraform apply

# Apply the configuration: Skip approval
terraform apply -auto-approve

# Shell output:
resource_group_id = "/subscriptions/12345678-1234-some-id.../resourceGroups/terraform-playground"
vm_private_ips = {
  "VM-1" = "10.0.10.4"
  "VM-2" = "10.0.10.6"
  "VM-3" = "10.0.10.5"
}
vm_public_ips = {
  "VM-1" = "104.40.228.166"
  "VM-2" = "52.166.61.159"
  "VM-3" = "23.97.242.234"
}

Output VM IP
#

# Refresh the Terraform state to get the VM public IP
terraform refresh
# Manually list the VM IP addresses (after the refresh)
terraform output vm_public_ips
terraform output vm_private_ips

# Shell output:
terraform output vm_private_ips
{
  "VM-1" = "104.40.228.166"
  "VM-2" = "52.166.61.159"
  "VM-3" = "23.97.242.234"
}
{
  "VM-1" = "10.0.10.4"
  "VM-2" = "10.0.10.6"
  "VM-3" = "10.0.10.5"
}

Delete Resources
#

# Delete resources: Ask for confirmation
terraform destroy

# Delete resources: Auto approve
terraform destroy -auto-approve

Links #

# Access Azure Portal
https://portal.azure.com
Terraform - This article is part of a series.
Part 3: This Article