HashiCorp Terraform Logo

Introduction

Variables in my experience are one of the most important components to making your Terraform Modules re-usable and self-documenting. This post will break down some of my recommendations and what I believe are some critical standards to have in place, wherever infrastructure as code is being used at scale.

Over the past 4 years, I’ve spent a fair amount of time writing Terraform approaching 500 hours of it since I started tracking with Wakatime. But more importantly, I’ve been privileged to work with people from all over the world who really really know their Terraform! Starting back in the days of Terraform v0.10.0 things were a bit simpler, everything was string-based and was, in my opinion, a lot easier to get started with. But now with the Terraform v1.1.0 on the verge of release, there has been some major capability uplifts, especially around modules, that make standardised variables more important than ever.

What are Variables?

Variables, also known within HashiCorp’s Terraform as Input Variables, are described by HashiCorp as; input parameters for Terraform Modules. Enabling the module to be customised based on the inputs provided by the user, without having to make changes to the module’s source code.

To me they are much more, they provide the potential a detailed insight into the workings of a module, and as documentation for the user. They are the doorway to unlocking the true scale of capability for your module, without them, your module.

It is when you are utilising Terraform modules at a significant scale that you need to ensure there is consistency across common variable names, as an example within Azure, using resource_group_name and every other module using rg_name or resource_grp_name. These small changes add up and when using multiple modules as building blocks for smaller solutions can slow you down significantly, especially when troubleshooting.

Variables, referred to as a var within Terraform, is comprised of five specific, but optional arguments:

  • default
  • type
  • description
  • validation
  • sensitive

Each variable is also required to be given a name which can be any identifier other than: source, version, providers, count, for_each, lifecycle, depends_on, locals.

Declaring a Variable

Declaring a variable is extremely simple to do, all that is required is to declare a variable block. An example of which is below:

variable "resource_group_name" {
  description = "(Required) The Name which should be used for this Resource Group. Changing this forces a new Resource Group to be created.."
  type        = string
}

As you can see from the above the declaration itself is quite simple, but as with many things in life, it’s doing the simple things right that maximises value.

Variable Names

Naming something, in my view, is one of the hardest things to do in technology, it’s always wrong to someone!

In Terraform the name of the variable immediately proceeds the variable identifier itself. Highlighted below:

Variable Name

The names of variables should be simple and assume none or very little pre-existing knowledge by the user. They need to remain relevant to the required input without having a deep insight into the resources being deployed or configured, this is of course within reason. It is a fine lining between oversimplification and the removal of key pieces of information. A few tips I always attempt to follow are listed below:

The use of shorthand or acronyms is more likely to confuse than it is to enable.

Whilst this can be tedious, you will be thanked by the vast majority of those who utilise your module in the long run. Ensuring the use of resource_group_name over rg_name, or even log_analytics_workspace_name instead of law_name, will minimise the risk of prerequisite knowledge or context for consuming your module.

Context is clear when writing code but often lost on others.

As you develop your Terraform module you will have a clear use case or outcome in mind, which others may not see as easily. This is especially true when building composite modules, also known as a module of modules, as you may have a large number of variables. Where you may be making assumptions or decisions throughout the development of the module. But it is important to remind yourself, how easy will this be to use for someone else?

Ordering

One of the things I’ve found amazingly beneficial which seems like quite a small thing, but has a large impact is defining an order for the variable arguments we mentioned above. I’ve found that the below order has worked the best for the teams I’ve worked with:

  1. description
  2. type
  3. default
  4. sensitive
  5. validation

I’ve noticed that when this order is used, it helps all people but especially newcomers to Terraform to think through their variables. The use of a description first ensures they have thought through the variable end to end, before they declare the type. This is especially handy when you start to craft more complex variable types.

Variable Descriptions

Descriptions are arguably the most important and often overlooked pieces of information when creating variables within Terraform.

A description should concisely explain the purpose of the variable and what kind of value is expected. It is important to note that if using a tool such as terraform-docs, then your description will likely for part of a README.md. Because of this, we should ensure that variable descriptions are written from the perspective of a user of the module rather than the maintainer.

It is always my recommendation that variable descriptions are always prefixed with the following two phrases. (Required) or (Optional). This enables that with a glance the user will known if input is needed. See the Variable Default Values section for more information.

The use of the two phrases is quite simple, if there is no default value, then the description of the variable is prefixed with (Required), as there is a required input from the user. If there is a default value present, then (Optional) will be used as the prefix as there isn’t a firm requirement to override the default value.

As a side note, if there is an (Optional) variable it is incredibly useful to ensure the default value is included within the description itself. Below are a few examples of variables demonstrating this convention.

variable "resource_group_name" {
  description = "(Required) The Name which should be used for this Resource Group. Changing this forces a new Resource Group to be created."
  type        = string
}

variable "virtual_machine_size" {
  description = "(Optional) The SKU which should be used for this Virtual Machine, such as Standard_F2. Defaults to 'Standard_D4s_v3'."
  type        = string
  default     = "Standard_D4s_v3"
}

Multi-Line Descriptions

It is only a matter of time before you come across a module that is taking advantage of dynamic blocks, introduced back in Terraform v0.12.0, often used alongside more complex variable input types such as object() and object maps object({}). This powerful tag team of dynamic blocks and complex variable inputs whilst making Terraform code DRY-er added complexity to our descriptions! Here’s how I look to standardise tackling this problem, by taking advantage of the support of the Here Document syntax:

variable "azure_devops_configuration" {
  description = <<EOF
    (Optional) A azure_devops_configuration block as defined below.

    (Required) account_name - Specifies the Azure DevOps account name.
    (Required) branch_name - Specifies the collaboration branch of the repository to get code from.
    (Required) project_name - Specifies the name of the Azure DevOps project.
    (Required) repository_name - Specifies the name of the git repository.
    (Required) root_folder - Specifies the root folder within the repository. Set to / for the top level.
    (Required) tenant_id - Specifies the Tenant ID associated with the Azure DevOps account.
  EOF

  type = object({
    account_name    = string
    branch_name     = string
    project_name    = string
    repository_name = string
    root_folder     = string
    tenant_id       = string
  })

  default = null
}

It is important to note that within the use of the Here Document syntax I’ve followed a few of our previous standards and introduced a few more.

Because the overall variable has a default value, the overall variable description is prefixed with (Optional). However, after a New Line for readability, the variable description block has all subsequent values as (Required). This is because when a value other than the default is used all of the map values within the object() are required to be assigned a value. At least today this is the case, hopefully, with the continued development of the Optional Object Type Attributes we will soon have optional object values within a variable.

Variable Types

The type argument in a variable block allows you to restrict the type of value that will be accepted as the value for a variable. If no type constraint is set then a value of any type is accepted.

Because of this I highly recommend that all variables are accompanied by a defined type. Not only does this help users of the module understand what is required as an input, but they also allow Terraform to return a helpful error message if the wrong type is used.

There are a number of supported type keywords and type constructors those are:

  • string
  • number
  • bool

Or for more complicated type constructors you can use collections such as:

  • list(<TYPE>)
  • set(<TYPE>)
  • map(<TYPE>)
  • object({<ATTR NAME> = <TYPE>, ... })
  • tuple([<TYPE>, ...])

Variable Default Values

Default Values are an important part of enabling simplification via abstraction when it comes to Terraform modules. It is the default values that are set within Terraform modules that enable specific actions to occur without input, which can be used to enable an abstraction away from the user and simplify the deployment of the resources within a terraform module.

As previously mentioned it is the use of default values that work with the standard of prefixing variable descriptions with (Required) or (Optional). These specific actions can be as simple as ensuring that a location variable defaults to a support region for the module.

variable "location" {
  description = "(Optional) The Azure location where the Linux Virtual Machine should exist. Defaults to 'australiasoutheast'. Changing this forces a new resource to be created."
  type        = string
  default     = "australiasoutheast"
}

Or it could be used to ensure that the source_image_reference of a Windows virtual machine defaults to Windows Server 2022 SKU:

variable "source_image_reference" {
  description = <<EOF
    (Optional) A source_image_reference block as defined below. Changing this forces a new resource to be created.

    (Required) publisher - Specifies the publisher of the image used to create the virtual machine.
    (Required) offer - Specifies the offer of the image used to create the virtual machine.
    (Required) sku - Specifies the SKU of the image used to create the virtual machine.
    (Required) version - Specifies the version of the image used to create the virtual machine. Set to 'latest' if unsure.
  EOF

  type = object({
    publisher = string
    offer     = string
    sku       = string
    version   = string
  })

  default = {
    publisher = "microsoftwindowsserver"
    offer     = "windowsserver"
    sku       = "2022-datacenter-azure-edition"
    version   = "latest"
  }
}

Variable Validation

Since its introduction into Terraform in v0.13.0 Custom Variable Validation has been critical in ensuring that users inputs are validated to specific rules when configuring a Terraform module. The validation block is used to define whether or not the value of the variable is either true or false as an output of the condition applied.

Validation rules are made up of two arguments, the condition which is the expression to define the rule and therefore the outcome being either true or false and the error_message which should be a string, of at least one full sentence explaining the validation rule and what the constraint is likely to have been causing a failure.

This can be used for simple variable validation such as ensuring that the length of the name of an Azure Resource Group is less than the limit of characters (90).

variable "resource_group_name" {
  description = "(Required) The Name which should be used for this Resource Group. Changing this forces a new Resource Group to be created."
  type        = string

  validation {
    condition     = length(var.resource_group_name) < 90
    error_message = "The resource_group_name must be less than 90 characters."
  }
}

Or for much more significant variable validation the use of a can() function which can be used to evaluate inputs against regex or other functions. Also supported is the use of multiple validation statements against a single variable. The example below shows how multiple variable validations can be used to ensure a correct value is returned, in this case for a Windows Virtual Machine Computer Name.

variable "computer_name" {
  description = (Optional) The computer name of the Windows Virtual Machine. Changing this forces a new resource to be created. Defaults to name variable if not specified.
  type        = string
  default     = null

  validation {
    condition     = length(var.computer_name) <= 15
    error_message = "The Windows computer name must be no longer than 15 characters."
  }

  validation {
    condition     = can(regex("^lachie-", var.computer_name))
    error_message = "For this example the NetBIOS name must be valid and start with 'lachie-'."
  }
}

Ordering of Variables

The order of the variables that are consumed within the module configuration should be mimicked within the variables.tf file. This should also remain true across a fleet of modules for common variables.

This means that if the first three variables consume within the main.tf of my windows virtual machine module are name, resource_group_name and location, then the first three declarations within the variables.tf should echo this order.

The only exception to this is where a variable such as tags is used. Because this is typically used across multiple resources it should always be the last variable that is declared in a variables.tf file.

Final Thoughts

Whilst this is by no means a comprehensive list of the do’s and don’ts of Variables for Terraform, it is aimed at defining a few key practices that can enable Terraform modules to be defined, consumed and maintained at scale.