My Terraform Standards

Brendan Thompson • 26 November 2021 • 9 min read

Following on from my last post My Terraform Development Workflow, I felt it might be a good idea to talk a little about the standards that I employ when writing Terraform. I will break this post up into a few sections:

Naming#

Naming is a highly touchy subject for many people, especially when it comes to naming cloud resources. This won't delve into the naming of the resultant resources provisioned by the Terraform code, only the names of blocks etc., within Terraform itself. The best way to do this is to lay them out as rules that can be looked through; this will help guide you to the right choice.

  1. When naming a Resource/Data Source/Input Variable/Output Value/Local Value the name MUST use Snake Case. Doing so ensures that visually the names align with how Terraform represents its internal names.

    resource "scratch_string" "example_string" {
      ...
    }
    
  2. Names MUST be lowercase.

  3. If there is only a single instance of a given Resource or Data Source, then it SHOULD be named this. This means that you do not have to think of a descriptive name for the resource, as the resource type itself should be descriptive enough. In the event that your code grows to include multiple instances of resource that cannot be within a loop you SHOULD give subsequent resources a descriptive name.

    resource "scratch_number" "this" {
      ...
    }
    
  4. When there are multiple instances of a given Resource or Data Source, the name SHOULD describe its function or domain.

    resource "azurerm_virtual_network" "hub" {
      // A Virtual Network that will act as a Hub
      ...
    }
    
    resource "azurerm_virtual_network" "shared_services" {
      // A Virtual Network that will house shared services
      ...
    }
    
  5. The name MUST not include information that is already present in the Resource/Data Source type name.

    resource "azurerm_virtual_network" "hub_virtual_network" {
      // 🚫 Do not do this
      ...
    }
    

Input Variables#

Input Variables or Variables or IVs are the most straightforward way to get information into your Terraform code. These are the pieces of information that instruct the Terraform code on what to build. As such, they are essential to get right, not only for you but for anyone consuming them!

The following is an example of what I would call a good IV, have a look at it, and we will deconstruct why it is good below.

variable "environment" {
  type        = string
  description = <<EOT
    (Optional)  The name of the environment where the resources will be deployed.

    Options:
      - dev
      - uat
      - test
      - prod

    Default: dev
  EOT

  default = "dev"

  validation {
    condition     = can(regex("dev|uat|test|prod"), var.environment)
    error_message = "Err: environment name is not valid."
  }
}
  1. The type MUST be explicit.

  2. The description MUST be, as it says, descriptive.

    • This should include if it is Optional or Required; this is determined by if default is set or not.
    • If there are a static set of options (think enum), this SHOULD be documented. This makes it easy for consumers to know if what they're passing in is valid or not.
    • If the default is set, its value MUST be documented.
  3. The default SHOULD be set sparingly; consumers should be explicit about what they are passing in.

  4. If the variable has any specific requirements, validation MUST be set. This prevents invalid data from entering the Terraform code and ensures that it will error sooner rather than later.

  5. If you're passing in a known data structure, you SHOULD favour the object() type, and this SHOULD be coupled with validation blocks.

If these rules are followed, it makes understanding what an Input Variable does and any requirements might have been easy.

Local Values#

When you want to do some transformation of data based on Input Variables, or there is a piece of data you wish to pass around to multiple places Local Values is a fantastic option.

  1. If a file contains a locals block, it SHOULD be at the very top of the file.
  2. If a locals block is transforming a specific piece of data and only used a single time it SHOULD be directly above the Resource that is doing the transformation for.
  3. Nesting and/or creating complex objects within
  4. If you have locals that are global and your code is split across domain based files then you SHOULD put those locals within a locals.tf file.

Constraints#

Constraints can be put in three places in Terraform; on the version of Terraform to use, on the version of the provider to use, and finally, on the version of a module to use.

  1. The version constraint SHOULD be applied to all three of these areas.
  2. You SHOULD use the pessimistic constraint operator on the minor version. Doing so will allow minor versions to increment and keep your code up to date without letting any breaking changes come through.
module "important_resources" {
  version = "~> 1.0"
}

The above will allow the use of 1.1.0, 1.0.1 and 1.1.1 but will not allow the use of 2.x.x.

Meta-arguments#

There are a 5 types of meta-arguments within the Terraform configuration language:

I will group count and for_each into a category called loops, as that is what they're predominately used for.

Loops#

If the Resource, Data Source or Module requires more than one instance of itself then a loop is used.

  1. If the configuration of each instance of the resource is identical, then a count SHOULD be used.
  2. If the resource is required to be turned on or off like a feature flag, then a count SHOULD be used.
  3. If the order of the resources is not going to change, then a count SHOULD be used.
  4. If the order of the resources is a concern, then for_each SHOULD be used.
  5. If the configuration of each instance of the resource will change or depend on a value provided by the loop, then for_each SHOULD be used.
  6. If you are unsure which loop type to use, then a for_each SHOULD be used.

I prefer to use a for_each loop in nearly all circumstances. You have a lot more control over them and if you're looking to reference the instances they produce, it is much easier to do so with a key provided by for_each rather than an index provided by count.

Depends On#

Whilst Terraform will implicitly depend on other Resources or Data Sources when other resources reference them. It is sometimes necessary to do this explicitly. This is done via the depends_on meta-argument. This meta-argument allows you to explicitly depend on one or more other resources and data sources.

There are also instances where errors can occur on destruction or modification if you do not explicitly depend on a resource down the chain. I generally adopt an implicit dependency is okay model and change to explicit as required.

Provider#

The provider meta-argument is extremely important when you're working with Terraform configuration that requires the use of multiple instances of the same type of provider.

  1. If you're writing a module, you SHOULD NOT use the provider meta-argument.
  2. The alias attributed to the provider meta-argument MUST be descriptive.

There is not much more to say about this one, other than think carefully about when and if you need to use it. The more instances of a provider you have, the more complex the code become. It is currently not possible to loop/iterate over instances of providers; thus, this meta-argument MUST be used any time you wish to use a different provider instance.

Lifecycle#

lifecycle is very powerful as it allows you to manipulate the lifecycle of the Resources or Data Sources that you declare. I seldom use this meta-argument, the only time I find it necessary is when auto-scaling is applied to resources or the resource is modified out of Terraform and cannot be controlled through Terraform. An instance of this would be if your cloud platform applies a change to a resource, and you cannot prevent it.

I highly recommend reading through the lifecycle meta-argument documentation here. This might be a topic that I dedicate a post to in the future.

Code Structure#

The code structure is fundamental; it determines how readable and clear our code is not only to us but to those wanting to consume and contribute. It is a good idea to make sure that you have a consistent approach to code structure. I follow the below set of rules whenever I write Terraform.

  1. You SHOULD use a single file for each of the following:
    • outputs.tf
    • variables.tf
    • providers.tf
    • backend.tf
    • versions.tf
  2. You SHOULD start with a single main.tf file and expand as required. (Refer to my previous post)
  3. locals blocks SHOULD be declared at the top of your main.tf.
  4. Data Sources that are going to be used globally SHOULD be declared at the top of the main.tf under the locals.
  5. Data Sources and locals that are specific to a particular resource or set of resources SHOULD be declared above them if they are only being used in that place.
  6. Any loops SHOULD be declared at the top of a resource, and a line of whitespace between it and any configuration for the resource.
  7. The depends_on meta-argument SHOULD be the last thing declared within a resource or data source and SHOULD be preceded with a single newline.
  8. The lifecycle meta-argument SHOULD be at the bottom of a resource or data source but above the depends_on if present.
  9. The provider meta-argument SHOULD be at the top of a resource or data source but below loops if they're present.

Closing Out#

Hopefully, this post gives you some ideas on how to structure your Terraform code and implement some standards that you can use to improve your code. I am always keen to hear how others deal with the standards side of Terraform as it can be pretty contentious. HashiCorp certainly never want to get involved in the religious war that is Terraform standards/best practices!

Brendan Thompson

Principal Cloud Engineer

Azenix

Discuss on Twitter