Terraform Best Practices: Do Not Hard Code Values

I want to start capturing some best practices with the intent of keeping them simple and slowly building them up in the coming weeks. I get to observe so many people learn Terraform and I see patterns emerge as better understanding occurs.

How I get to observe people learn Terraform:

  • My primary role at CDW is as a consultant that mentors new consultants with respect to both technical and consulting skills. I share the responsibility of developing curriculum for our consultants, which includes foundational skills in networking and security, cloud platforms, and tooling like Terraform.
  • In additional, I help lead customers through the adoption and maturity of their Infrastructure as Code and Platform Engineering strategies. This includes helping them to learn Terraform and a broader ecosystem of tooling that contributes to a winning operating model driving towards self-service.
  • I also am an HashiCorp Authorized Instructor and deliver their training in Terraform to their customers, including the Terraform Foundations and Advanced Terraform courses.
  • Beyond this, I work independently to develop training with Digital Cloud Training for Terraform included in their Cloud Mastery Bootcamp which is a year-long program focused on developing foundational skills, a deep understanding of AWS, and adjacent tooling with Terraform and Kubernetes.

There is no question that hard coding values can work; the tutorials all start off with having students use this method to create their first Terraform code. However, I even see people that are well acquainted with Terraform continuing to use hard coded values. This could make sense if it is simply some quick example code, but that it isn’t limited to this. No values assigned to properties (with the exception of when it is required, like in the terraform block) should be hard coded.

If we work through a quick example to create an Azure Resource Group (it doesn’t cost anything), it is pretty easy to create the required Terraform code (as opposed to an Azure Resource Manager JSON template that is about 40 lines of code). It takes only four lines of code:


resource "azurerm_resource_group" "rg" {
name = "rg-eus-test-myrg"
location = "eastus"
}

If we include this with our provider block and authenticate to a subscription, we can run a terraform plan and apply which will manifest an Azure Resource Group in the East US region named: rg-eus-test-myrg. It is among the simplest examples that actual deploys a resource in a cloud provider. This isn’t a profound declaration, but I observe it routinely.

As a best practice, none of these values, or other that we could add, should be hard coded. In a recent conversation, I heard a statement that things that should always be set this way can be hardcoded. It is still bad.

The options begin with the more obvious statements.

Use Variables

Terraform and HCL have a built-in construct for variables which is table stakes for any coding toolset. Even if you don’t intend to change the values, variables can easily accommodate your practices. As a bonus, generative tooling like GitHub Copilot readily recognizes what we’re doing when we start to improve this code and helps us out; if we make variables for “resource_group_name” and “location” a default value can be set and Copilot will fill it in with our hard coded value:


variable "location" {
default = "eastus"
description = "Azure deployment region"
type = string
}
variable "resource_group_name" {
default = "rg-eus-test-myrg"
description = "Resource Group name for deployment"
type = string
}
resource "azurerm_resource_group" "rg" {
name = var.resource_group_name
location = var.location
}

The outcome of running our code is identical if we don’t alter the input values for these variables. The added benefit is that now this code is reusable as we can supply different input values and deploy another resource group instead.

However, the concern may arise that we may want to protect these values. Protection could arise through a number of means, but we won’t be covering them all today.

Use Locals

If you’re desired outcome is to protect these values from being changed through input values, the locals block gives us another opportunity to remove the hard coded values from our resources, data sources, and module definitions. We can use these blocks to set constants, manipulate and filter data, or simply to combine data. These blocks are also a great place to handle logic rather than attempting to do so in the expression that assigns the value to a property.

As an example, we’ll add tags to our resource group, with a variable, but we also want to have some default tags as well as mandatory tags that cannot be overridden:


variable "location" {
default = "eastus"
description = "Azure deployment region"
type = string
}
variable "resource_group_name" {
default = "rg-eus-test-myrg"
description = "Resource Group name for deployment"
type = string
}
variable "tags" {
default = {
Environment = "test"
}
description = "Supplied Resource Group tags"
type = map(string)
}
locals {
mandatory_tags = {
CreatedBy = "Terraform"
}
tags = merge(var.tags, local.mandatory_tags)
}
resource "azurerm_resource_group" "rg" {
name = var.resource_group_name
location = var.location
tags = local.tags
}

One additional consideration for those that are ahead of this discussion, having “constants” are fine in a root module to incorporate business requirements and practices, but should be avoided in most reusable modules. Capturing best practices as default within modules is preferred, but if we’re trying to drive as much reuse as possible, we shouldn’t attempt to protect values because exceptions always exist. For instance, creating cloud storage often comes with a policy decision of setting it for private access, only. This is a good starting point and tools like Checkov will give you warnings if you define an Azure Storage Account or AWS S3 Bucket with anything other than private access. However, if your intent is to use the storage to host static web content, that is a necessary exception to that best practice. We dive into that topic much more deeply when we discuss writing modules.

Ensure Terraform Can Build Dependencies

I’ll wrap this up with one final topic related to this. If you use a tool like Azure Terraform Export Tool or the preview -generate-config-out flag with a config-driven import, it will create Terraform code with hard coded values. This code is functional and represents the state of the resources at the time of the import. We reviewed why the hard coded values are bad. However, one nuance to this emerges with respect to dependencies. Since we already have defined the Resource Group name as a variable, we could use that variable when deploying additional resources, such as a Virtual Network:


variable "address_space" {
default = ["10.0.42.0/24"]
description = "Virtual Network address space"
type = list(string)
}
variable "virtual_network_name" {
default = "vnet-eus-test-myvnet"
description = "Virtual Network name for deployment"
type = string
}
resource "azurerm_virtual_network" "vnet" {
name = var.virtual_network_name
address_space = var.address_space
location = var.location
resource_group_name = var.resource_group_name
}

This creates a fairly consistent challenge. No problem emerges if the code was created incrementally until the code is reused, or a destroy and recreate is attempted. By reusing the variable, Terraform has no awareness of the relationship between the Virtual Network and the Resource Group. This often leads to errors when executing the code and then the potential for relying on the depends_on meta-argument arises.

Instead, always have related resources reference information from the other resources. The fix here is the complete code:


variable "address_space" {
default = ["10.0.42.0/24"]
description = "Virtual Network address space"
type = list(string)
}
variable "location" {
default = "eastus"
description = "Azure deployment region"
type = string
}
variable "resource_group_name" {
default = "rg-eus-test-myrg"
description = "Resource Group name for deployment"
type = string
}
variable "tags" {
default = {
Environment = "test"
}
description = "Supplied Resource Group tags"
type = map(string)
}
variable "virtual_network_name" {
default = "vnet-eus-test-myvnet"
description = "Virtual Network name for deployment"
type = string
}
locals {
mandatory_tags = {
CreatedBy = "Terraform"
}
tags = merge(var.tags, local.mandatory_tags)
}
resource "azurerm_resource_group" "rg" {
name = var.resource_group_name
location = var.location
tags = local.tags
}
resource "azurerm_virtual_network" "vnet" {
name = var.virtual_network_name
address_space = var.address_space
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
}

Final Thoughts

This was a lot more discussion than I expected it to be when I thought about preserving these thoughts. I guess that is just a testament to how much thoughtful consideration can be given to matters of craftsmanship and quality. For those that are seasoned with respect to Terraform, hopefully this at least provides some perspective on the narrative that can revolve around the topic. For those of you that disagree, reserve judgement for later as the best practices build on each other. While you may not be sold on these aspects now, it enables other best practices and use cases.

Leave a comment