Terraform on Azure - Modules for Windows Virtual Machines

azure terraform modules virtualmachine virtualnetwork iac microsoft hashicorp devops cloud

This post explains a reusable Terraform module, for deploying Windows virtual machines on Azure

Introduction - why and when should you use modules?

You have already gained some experience with creating Terraform configurations to deploy different resources. For example: a configuration, which provisions a single Windows virtual machine on Azure. Imagine that you’d need a configuration that can be used for deploying two instances of that Windows virtual machine instead of one: this means that you’d need to adapt the existing configuration, which might lead to code duplication. In that situation, you’d be glad to have a solution to reuse your configuration somehow. At that time, you are ready to write your own modules. In this post, I’d like to explain the concept of Terraform modules, with examples of Windows virtual machines.

Reference

https://developer.hashicorp.com/terraform/language/modules

Deploying two Windows virtual machines without using modules

Imagine you don’t want to use Terraform modules for writing a configuration that deploys two Windows virtual machines. The Terraform configuration below shows a possible solution for that, distributed to three files:

providers.tf

The mandatory Terraform block and the provider block are included in the “providers.tf” file. The state file will be managed by using a Storage Account. That’s optional - simply delete the backend block if you would like to manage the state file locally.

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

    random = {
      source  = "hashicorp/random"
      version = "3.1.0"
    }
  }

  # Delete the backend block if you want to store the state locally
  backend "azurerm" {
    resource_group_name  = "devopsexperiences-storage"
    storage_account_name = "alien39"
    container_name       = "terraformstate"
    key                  = "terraform.tfstate"
  }

  required_version = ">= 0.14.9"
}

provider "azurerm" {
  features {}
}

variables.tf

A variable is defined for the password of the virtual machine, respectively for the virtual machine size:

variable "my_virtual_machine_password" {
  default     = "P@$$w0rd1234!"
  description = "Password of the Virtual Machine"
}

variable "my_virtual_machine_size" {
  default     = "Standard_D2_v4"
  description = "Size of the Virtual Machine"
}

main.tf

Don’t be scared, the following file seems to be complex and much too looooong: it is including resource blocks for two Windows virtual machines, named “windows11-vm-1” and “windows11-vm-2”. A virtual machine must have a network interface, and for establishing an rpd connection a public IP address. In addition, a network security group is also included by using the corresponding resource block. The network security group can be used for both virtual machines, but this does not work for the IP address, respectively for the network interface. Because of that, you can notice code duplication in the “main.tf” file. Nevertheless, it is a valid configuration which is capable for deploying two instances of a Windows 11 virtual machine:

resource "azurerm_resource_group" "rg" {
  name     = "iac-azure-terraform"
  location = "westeurope"
}

resource "azurerm_availability_set" "myavailabilityset" {
  name                = "example-aset"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
}

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
}

resource "azurerm_subnet" "subnet" {
  name                 = "internal"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["10.0.2.0/24"]
}

resource "azurerm_public_ip" "my-public-ip-vm-1" {
  name                = "public-ip-vm-1"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  allocation_method   = "Dynamic"
}

resource "azurerm_public_ip" "my-public-ip-vm-2" {
  name                = "public-ip-vm-2"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  allocation_method   = "Dynamic"
}

resource "azurerm_network_interface" "networkinterface-vm-1" {
  name                = "my-network-interface-vm-1"
  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.my-public-ip-vm-1.id
  }
}

resource "azurerm_network_interface" "networkinterface-vm-2" {
  name                = "network-interface-vm-2"
  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.my-public-ip-vm-2.id
  }
}

# Windows 11 Virtual Machine
resource "azurerm_windows_virtual_machine" "my-vm-1" {
  name                = "windows11-vm-1"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  size                = var.my_virtual_machine_size
  admin_username      = "adminuser"
  admin_password      = var.my_virtual_machine_password
  availability_set_id = azurerm_availability_set.myavailabilityset.id
  network_interface_ids = [
    azurerm_network_interface.networkinterface-vm-1.id,
  ]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "MicrosoftWindowsDesktop"
    offer     = "windows-11"
    sku       = "win11-21h2-avd"
    version   = "latest"
  }
}

resource "azurerm_windows_virtual_machine" "my-vm-2" {
  name                = "windows11-vm-2"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  size                = var.my_virtual_machine_size
  admin_username      = "adminuser"
  admin_password      = var.my_virtual_machine_password
  availability_set_id = azurerm_availability_set.myavailabilityset.id
  network_interface_ids = [
    azurerm_network_interface.networkinterface-vm-2.id,
  ]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "MicrosoftWindowsDesktop"
    offer     = "windows-11"
    sku       = "win11-21h2-avd"
    version   = "latest"
  }
}

# Security Group - allowing RDP Connection
resource "azurerm_network_security_group" "sg-rdp-connection" {
  name                = "allowrdpconnection"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

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

  tags = {
    environment = "Testing"
  }
}

# Associate security group with network interface
resource "azurerm_network_interface_security_group_association" "vm-1" {
  network_interface_id      = azurerm_network_interface.networkinterface-vm-1.id
  network_security_group_id = azurerm_network_security_group.sg-rdp-connection.id
}

resource "azurerm_network_interface_security_group_association" "vm-2" {
  network_interface_id      = azurerm_network_interface.networkinterface-vm-2.id
  network_security_group_id = azurerm_network_security_group.sg-rdp-connection.id
}

By applying it, you will see the instances of the Windows virtual machines after a few minutes in the Azure Portal:

azure_two_win11_vms_overview

Reviewing the configuration

In a nutshell: the Terraform configuration deploys (as expected) the virtual machines, which is the purpose of it. Probably you are still not that happy with that code, because of the mentioned code duplication. Following Terraform resource blocks occur always twice, which are almost identical:

You can find this Terraform configuration at: github.com/patkoch - Terraform on Azure - Windows 11

The next chapter will explain a a different way to create a Terraform configuration, which can be used for deploying two different Windows virtual machines - by using Terraform modules.

Deploying two Windows 11 virtual machines using modules

What is a module?

When we talk about a module, then we refer to several .tf (and/or .tf.json) files, which are located in a directory. Sounds like the definition of an ordinary Terraform configuration, right? That’s true, because each Terraform configuration (for example, the one in the previous chapter) already has a module - which is named root module. But the intention of the .tf files of a (child) module is to be reusable. The configuration of the chapter “Deploying two Windows 11 virtual machines without using modules” proved that it’s not intended for being reused. For instance: if you would like to increase the instances of the virtual machines, then you need to adapt the main.tf file.

Reference

https://developer.hashicorp.com/terraform/language/modules

The structure of the modules

The following Terraform configuration, which used modules, can be found at github.com/patkoch - Terraform on Azure - Modules Windows virtual machines Let’s figure out how the deployment of two virtual machines works by using the Terraform modules:

I’d like to start with the structure of the files, respectively the directories, which can be seen in the picture below:

structure

There are five directories and several Terraform files:

“network” and “vm-windows” serve as so called child modules, and the content of the directory “windows_vms” can be refered as root module - because it is possible to call the modules “network” and “vm-windows” several times from it.

Calling the child modules from the root module

I’m calling the mentioned child modules (“network” and “vm-windows”) from the root module within the file “main.tf” (“examples/windows_vms”). Let’s explain this file in a little more detail:

examples/windows_vms/main.tf

The statement starting with source refers to the location of the configuration of the corresponding child module, e.g.:

source = "../../modules/vm-windows"

The content of that file looks organized: at first a resource group is defined. After that, there are three calls of the child modules:

The first call is about the deployment of network-related resources. The second and the third call are about the creation of the virtual machines:

resource "azurerm_resource_group" "rg" {
  name     = "${var.tenant_name}-rg"
  location = var.resource_group_location
}

module "network" {
  source = "../../modules/network"

  tenant_name = var.tenant_name
  subnet_name = var.my_subnet_name
  resource_group_location = var.resource_group_location
  resource_group_name = azurerm_resource_group.rg.name 
}

module "vm-windows-10" {
  source = "../../modules/vm-windows"

  my_virtual_machine_name = var.my_virtual_machine_name_win10
  my_virtual_machine_password = var.my_virtual_machine_password_win10
  my_virtual_machine_size = var.my_virtual_machine_size
  location = var.resource_group_location
  resource_group_name = azurerm_resource_group.rg.name
  azurerm_subnet_id = module.network.azurerm_subnet_id
  source_image_offer = var.source_image_offer_win10
  source_image_sku = var.source_image_sku_win10
  network_interface_name = var.network_interface_name_win10
  public_ip_address_name = var.public_ip_address_name_win10
}

module "vm-windows-11" {
  source = "../../modules/vm-windows"

  my_virtual_machine_name = var.my_virtual_machine_name_win11
  my_virtual_machine_password = var.my_virtual_machine_password_win11
  my_virtual_machine_size = var.my_virtual_machine_size
  location = var.resource_group_location
  resource_group_name = azurerm_resource_group.rg.name
  azurerm_subnet_id = module.network.azurerm_subnet_id
  source_image_offer = var.source_image_offer_win11
  source_image_sku = var.source_image_sku_win11
  network_interface_name = var.network_interface_name_win11
  public_ip_address_name = var.public_ip_address_name_win11
}

How do you know, which assignments you have to insert inside of a “module” block? For that, check the “variables.tf” of the corresponding module. The code below shows the content of the “variables.tf” of the “network” module. There are four variables defined, and those four variables have to have real values. Those values will be set by calling the module from the root module.

modules/network/variables.tf

variable "tenant_name" {
  type    = string
  description = "Name of the tenant, respectively the organization"
}

variable "subnet_name" {
  type    = string
  description = "Name of the subnet"
}

variable "resource_group_location" {
  type    = string
  description = "Location of the resources"
}

variable "resource_group_name" {
  type    = string
  description = "Name of the resource group, which contains the resources"
}
}

examples/windows_vms/variables.tf

In my example, the concrete values are the default values of the “variables.tf” file from the root module:

variables_root_module

Reference

https://developer.hashicorp.com/terraform/language/modules

The child modules

This chapter is about explaining the child modules in more detail.

The network child module

Of course, we need a virtual network, including a subnet, etc. for the virtual machines. Therefore, the child module named “network” was created, which structure can be seen in the picture below:

child-module-network

The code below shows the content of the file “main.tf” of the “network” child module: it includes a resource block for the virtual network, and a dedicated resource block for a subnet:

modules/network/main.tf

resource "azurerm_virtual_network" "vnet" {
  name                = "${var.tenant_name}-vNet"
  address_space       = ["10.0.0.0/16"]
  location            = var.resource_group_location
  resource_group_name = var.resource_group_name
}

resource "azurerm_subnet" "subnet" {
  name                 = var.subnet_name
  resource_group_name  = var.resource_group_name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["10.0.2.0/24"]
}

The variables (starting with the prefix “var.”), are defined in the corresonding “variables.tf” file in the directory “network”.

Of particular interest is the file “output.tf”, which defines three outputs:

modules/network/output.tf

output "azurerm_virtual_network_name" {
    value = azurerm_virtual_network.vnet.name
}

output "azurerm_virtual_network_id" {
    value = azurerm_virtual_network.vnet.id
}

output "azurerm_subnet_id" {
    value = azurerm_subnet.subnet.id
}

What is the purpose of the “output.tf” file? By deploying a network, you will get values for the Azure virtual network name, the id, and the subnet id. Those values need to be available for the root module.

For instance:

The picture below shows the call of the child module “vm-windows” in line 15 which is done from the root module: the virtual machine needs among others a subnet id (see line 23), but the subnet id “is part” of the other child module - the network child module, which is called before. So, the subnet id already exists at this time, and therefore it is necessary to provide the information about the subnet id somehow to the root module. For that, outputs are defined to return the dedicated values.

examples/windows_vms/main.tf

module-value-in-root-module

Reference

https://developer.hashicorp.com/terraform/language/values/outputs

The vm-windows child module

The structure of that directory is similar to the “network” module: there are again four Terraform files included:

child-module-vms-github

Let’s have a closer look at the “main.tf” file: also, this file looks organized, without any duplication of any resource block. Almost all configuration options get their concrete values from a variable:

modules/vm-windows/main.tf

resource "azurerm_availability_set" "myavailabilityset" {
  name                = "example-aset"
  location            = var.location
  resource_group_name = var.resource_group_name
}

resource "azurerm_public_ip" "my-public-ip" {
  name                = var.public_ip_address_name
  resource_group_name = var.resource_group_name
  location            = var.location
  allocation_method   = "Dynamic"

  tags = {
    environment = "Testing"
  }
}

resource "azurerm_network_interface" "mynetworkinterface" {
  name                = var.network_interface_name
  location            = var.location
  resource_group_name = var.resource_group_name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = var.azurerm_subnet_id
    private_ip_address_allocation = "Dynamic"

    public_ip_address_id = azurerm_public_ip.my-public-ip.id 
  }
}

# Windows virtual machine
resource "azurerm_windows_virtual_machine" "myvirtualmachine" {
  name                = var.my_virtual_machine_name
  resource_group_name = var.resource_group_name
  location            = var.location
  size                = var.my_virtual_machine_size
  admin_username      = "adminuser"
  admin_password      = var.my_virtual_machine_password
  availability_set_id = azurerm_availability_set.myavailabilityset.id
  network_interface_ids = [
    azurerm_network_interface.mynetworkinterface.id,
  ]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "MicrosoftWindowsDesktop"
    offer     = var.source_image_offer
    sku       = var.source_image_sku
    version   = "latest"
  }
}

# Security Group for allowing RDP Connection
resource "azurerm_network_security_group" "sg-rdp-connection" {
  name                = "allowrdpconnection"
  location            = var.location
  resource_group_name = var.resource_group_name

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

  tags = {
    environment = "Testing"
  }
}

# Associate security group with network interface
resource "azurerm_network_interface_security_group_association" "example" {
  network_interface_id      = azurerm_network_interface.mynetworkinterface.id
  network_security_group_id = azurerm_network_security_group.sg-rdp-connection.id
}

In contrast to the “main.tf” file, the file “output.tf” (which can be seen in the code snippet below) is not very spectaculal: just the public address is returned after deploying the virtual machine.

modules/vm-windows/output.tf

output "azurerm_public_ip_address" {
    value = azurerm_public_ip.my-public-ip.ip_address
}

Applying the configuration including the modules

Now it’s time to deploy the virtual machines on Azure. For that, I’ll change the directory to “modules/examples/windows_vms”, to the root module, and which serves as an example of how to call the child modules.

Conducting terraform init already indicates, that modules are used:

terraform_init

After that, I’ll execute the commands terraform validate, and terraform apply:

terraform_apply

I’ll confirm my intention to the deployment of the resources by typing “yes” and after a few minutes, two Windows virtual machines are deployed. They also appear in the Azure portal:

azure_portal_module_vms

Summary

Whether you use the configuration without using modules or the one with modules - you’ll get the same result: the deployment of two Windows virtual machines.

In contrast to the Terraform configuration without using modules, a new instance of a virtual machine can be easily added by implementing an additional call of the child module “vm-windows” and by providing the concrete values for that. There is no need to adapt the content of the child modules, which define the resources related to the virtual machines and to the network.

So, Terraform modules are ideal if you would like to reuse your configurations. By implementing modules you will switch to a “hierarchical system”, as you are going to call your child modules (which can be done multiple times) from a root module. Be aware to follow best practices according to the composition of the modules, which can be found at the following link:

https://developer.hashicorp.com/terraform/language/modules/develop/composition

References

https://developer.hashicorp.com/terraform/language/modules https://developer.hashicorp.com/terraform/language/modules/develop https://developer.hashicorp.com/terraform/language/values/outputs https://developer.hashicorp.com/terraform/language/modules/develop/composition