Terraform best practices — how to use modules

Jack Roper
Apr 9 · 6 min read

When starting out with Terraform it’s hard to know what is considered ‘best practice’ in a number of areas.

This post is the second in the series which focuses on point 2 in the list, ‘use modules wherever possible’.

  1. Use a consistent file structure across your projects
  2. Use modules wherever possible
  3. Use a consistent naming convention
  4. Use a consistent format and style
  5. Hold your state file remotely, not on your local machine
  6. Avoid hardcoding variables
  7. Less resources in a project are easier and faster to work with
  8. Limit resources in the project to reduce the blast radius
  9. Test your code

What are ‘Modules’ in Terraform?

A Module is simply a collection of .tf configuration files that define multiple related resources, coded in such a way that the code can be re-used. These files are held in a folder.

If you use modules, you will have folders in your project structure. These modules (folders) can also be nested (sub-folders), although its recommended not to go more than 3/4 levels deep and to avoid this if possible to reduce complexity.

The root directory of the project can also be referred to as the ‘root module’, and any code in sub-folders is referred to as ‘child modules’. The code in the root module usually calls the child modules as required.

The above few describes the concept of ‘local modules’. You can also call modules from a private or public module registry. The ability to host your modules in a private module registry is a feature of Terraform Cloud.

Put simply — the benefit of using modules wherever possible is that coding effort will be greatly reduced when doing the same thing across multiple projects.

The Terraform registry has a huge collection of ready to use modules, saving you time and effort when it comes to coding for common tasks, e.g. instead of defining code for creating a VM in Azure, you could call the Azure VM module from the public registry.

Local Module Example

A module will consist of a set of .tf configuration files and the module will be called by some code in the root module.

Variables can be passed from the root to the child module, and also outputted from the child module back to the root.

As such, each module will typically contain a variables.tf, outputs.tf along with the necessary config file containing the resource definitions, e.g. vm.tf.

The following example will create a VM using a local module.

I have a folder called ‘vm’, in which there are 3 .tf configuration files


This file defines the group of resources required to create a VM in Azure.

resource "random_string" "nic_prefix" {
length = 4
special = false

resource "azurerm_network_interface" "vm_nic" {
name = "${var.vm_name}-nic1"
location = var.location
resource_group_name = var.resource_group_name

ip_configuration {
name = "${var.vm_name}_nic_${random_string.nic_prefix.result}"
subnet_id = var.vm_subnet_id
private_ip_address_allocation = "Static"
private_ip_address = var.static_ip_address
tags = var.tags

resource "azurerm_network_interface_security_group_association" "vm_nic_sg" {
network_interface_id = azurerm_network_interface.vm_nic.id
network_security_group_id = var.network_security_group_id
count = var.network_security_group_id == "" ? 0 : 1

resource "azurerm_virtual_machine" "windows_vm" {
name = var.vm_name
vm_size = var.vm_size
location = var.location
resource_group_name = var.resource_group_name

tags = merge(var.tags, { activityName = "${var.activity_tag} " })

network_interface_ids = [

storage_image_reference {
publisher = var.publisher
offer = var.offer
sku = var.sku
version = "latest"

identity {
type = "SystemAssigned"

storage_os_disk {
name = "${var.vm_name}-os-disk"
caching = "ReadWrite"
create_option = "FromImage"
managed_disk_type = "Standard_LRS"

os_profile {
admin_password = var.admin_password
admin_username = "azureuser"
computer_name = var.vm_name

os_profile_windows_config {
provision_vm_agent = true

delete_os_disk_on_termination = var.vm_os_disk_delete_flag
delete_data_disks_on_termination = var.vm_data_disk_delete_flag


This file outputs anything we want to pass back to the root for re-use.

output "vm_id" {
value = "${azurerm_virtual_machine.windows_vm.id}"

output "vm_name" {
value = "${azurerm_virtual_machine.windows_vm.name}"

output "vm_location" {
value = "${azurerm_virtual_machine.windows_vm.location}"

output "vm_resource_group_name" {
value = "${azurerm_virtual_machine.windows_vm.resource_group_name}"


This file defines all the variables the module expects to be defined in the call from the root module.

variable "resource_group_name" {

variable "location" {

variable "sloc" {

variable "vm_size" {
default = "Standard_B1s"

variable "vm_subnet_id" {

variable "vm_name" {

variable "vm_os_disk_delete_flag" {
default = true

variable "vm_data_disk_delete_flag" {
default = true

variable "network_security_group_id" {
default = ""

variable "static_ip_address" {

variable "publisher" {

variable "offer" {

variable "sku" {

variable "tags" {
type = map
description = "All mandatory tags to use on all assets"

default = {
activityName = "AzureVMWindowsDemo"
automation = "Terraform"
costCenter1 = "A00000"
dataClassification = "Demo"
managedBy = "bt@bt.com"
solutionOwner = "bt@bt.com"

variable "activity_tag" {

variable "admin_password" {

Calling the module

In main.tf in the root, the module is called and the required variables are passed to it. Note the source is pointing to the path of the local ‘vm’ folder. Also note that you can use outputs from other modules to pass into other child modules (the vm_subnet_id and admin_password here are defined in other modules and the values are outputted). You can define the variable value directly here, or read in from the root variable definitions.

module windows_desktop_vm_using_local_module {
source = "./vm"
resource_group_name = azurerm_resource_group.rg.name
location = "uksouth"
sloc = "uks"
vm_subnet_id = module.network.vnet_subnets[0]
vm_name = "tfdtlocmod"
vm_size = var.desktop_vm_size
publisher = var.desktop_vm_image_publisher
offer = var.desktop_vm_image_offer
sku = var.desktop_vm_image_sku
static_ip_address = ""
activity_tag = "Windows Desktop"
admin_password = module.vmpassword.secretvalue

root variables.tf

variables to be passed to the child module are defined here…

# read in from the terraform.auto.tfvars file

variable "subscription_id" {
variable "client_id" {
variable "client_secret" {
variable "tenant_id" {
variable "global_settings" {
variable "desktop_vm_image_publisher" {
variable "desktop_vm_image_offer" {
variable "desktop_vm_image_sku" {
variable "desktop_vm_image_version" {
variable "desktop_vm_size" {


The variable values are defined in this file.

subscription_id = "6840913c-76e6-488d-xxxx-0a27872c70e6"
client_id = "c0bcbf81-c51b-4ca2-xxxx-759c688e2d9f"
client_secret = "zNGdvqm7Ft.xxxxxxx"
tenant_id = "5759ecf2-97b4-4017-xxxx-4f0b25f016d2"

global_settings = {

#Set of tags
tags = {
applicationName = "Windows VM Demo"
businessUnit = "Technical Solutions"
costCenter = "MPN Sponsorship"
deploymentType = "Terraform"
environment = "Dev"
owner = "Jack Roper"
version = "0.1"


# Desktop VM variables
desktop_vm_image_publisher = "MicrosoftWindowsDesktop"
desktop_vm_image_offer = "Windows-10"
desktop_vm_image_sku = "20h1-pro"
desktop_vm_image_version = "latest"
desktop_vm_size = "Standard_B1s"

That's it! You now have a reusable bundle of code (module) you can use to create a VM on Azure.

Registry Module Example

Easier still is to call a module from the public registry. It can be considered less flexible to use public modules as you don't have direct control over the module code. I like to use them as the first port of call, if they can’t do a particular thing I want them to, then I will define a local module instead.

To create a VM on Azure, there is a single file called


Note the source here is pointing to “Azure/compute/azurerm

The variables required for the module can be found on the ‘inputs’ tab on the public registry module web page.

# Windows 10 desktop VM(s) 
module "windows_desktop_vm_using_registry_module" {
source = "Azure/compute/azurerm"
version = "3.10.0"
resource_group_name = azurerm_resource_group.rg.name
is_windows_image = true
vm_hostname = "tfdtregmod" // line can be removed if only one VM module per resource group
admin_username = "admin"
admin_password = module.vmpassword.secretvalue
public_ip_dns = ["tfdtregmod"] // change to a unique name per datacenter region
vm_os_publisher = var.desktop_vm_image_publisher
vm_os_offer = var.desktop_vm_image_offer
vm_os_sku = var.desktop_vm_image_sku
vm_size = var.desktop_vm_size
remote_port = "3389"
nb_instances = "1"
vnet_subnet_id = module.network.vnet_subnets[0]
tags = var.global_settings.tags

depends_on = [azurerm_resource_group.rg]

The variables.tf and terraform.auto.tfvars files will be the same as the local module example.

And that's it! As you can see there is a lot less code involved in using a public module. If you have Terraform Cloud or Enterprise, you can also host your local modules in a module repository to get the same benefits, and pull them down easily, rather than manually copying the code between projects.

Check out the Terraform docs for further explanation.

Thanks for reading and hit the follow button! Cheers! 🍻


Everything connected with Tech & Code

Jack Roper

Written by

An experienced IT professional, focused on Cloud and DevOps. Specialising in Azure & Terraform.



Everything connected with Tech & Code

Jack Roper

Written by




Everything connected with Tech & Code




Get the Medium app
