Blog

Creative Tactics for Writing a Well-Architected Terraform Module

Terraform is a powerful tool for managing infrastructure as code, and as your usage grows, so does the complexity of your modules.

This article is aimed at intermediate to advanced users who are familiar with terraform basics, but want to refine their approach to developing robust, maintainable modules. We’ll delve into design considerations, the importance of defensive programming, and provide practical examples to illustrate key points.

These are a handful of select mentions that we consider when we hand solution to our clients.

Group variables into objects via Sets/Maps

As modules grow larger, grouping related variables into objects can be beneficial. Using sets or maps for variable grouping allows for cleaner configuration and easier management.

An engineer may traditionally define the following variables as such:

variable "ec2_name" {
  type        = string
  description = "The friendly name of a EC2 instance that should be created."
}

variable "ec2_replica_count" {
  type        = number
  description = "The number of EC2 instances to create."
}

variable "ec2_ami" {
  type        = string
  description = "The image that should be used to create the EC2 instances."
}

variable "ec2_instance_type" {
  type        = string
  description = "The instance type or size that we should use."
}

If we were to continue down this path of individually defining variables rather than grouping, we would find it hard to continue to manage the solution if it were to continue to grow. We can work around this by performing something similar to the following:

variable "ec2" {
  type = object({
    name          = string
    ami_id        = string
    replica_count = number
    instance_type = string
  })
}

We can eliminate a lot of the repetitive boilerplate code by using the count argument to tell terraform that we want to loop over a resource. In this case, we can provide an number (var.ec2.replica_count) that represents the number of resources of this exact configuration that we want to create.

resource "aws_instance" "company_webservers" {
  count         = var.ec2.replica_count
  ami           = var.ec2.ami_id
  instance_type = var.ec2.instance_type
  tags          = {
    "Name" = "company_webserver_${count.index}"
  }
}

Set Defaults via Locals for your Variables

Providing default values for variables can simplify module reuse.

When defining a variable that is a set or a map and attempting to set a default value, a common gripe si that you have to populate all the keys instead of the individual ones that you would like to have a default.

It may be practical to either use terraform's coalesce or lookup function to define a local variable in circumstances where you are going to use the module that you are writing more than a handful of times.

locals {
    defaults = {
      ec2 = {
        # Example using Coalesce Function.
        instance_type = coalesce(var.ec2.instance_type, "t2.micro")
        # Example using Lookup Function
        name          = lookup(var.ec2, "name", "default_webserver_name")
      }
    }
}

Consider avoiding data sources in modules

One effective practice is to avoid using data sources within modules. Instead, call data sources in the root module and pass them as a variable to the child modules.

This approach can significantly reduce the number of calls made during the state and data refresh stage, which can become time-consuming as the number of managed resources and data sources grows.

There is an inevitable tradeoff of convenience, but if your module does grow significantly it may be more beneficial to implement this consideration if your deployment times are larger than desirable.

Using resource preconditions and postconditions as validation

Preconditions and Postconditions can be used to guarantee that resources follow a strictly defined behavior. This can be useful when performing changes later in a module when unexpected behavior change can manifest, as it could prevent a deployment where something may be broken.

resource "aws_instance" "company_web_server" {
  ami           = var.ec2.ami_id
  instance_type = var.ec2.instance_type

  lifecycle {
    precondition {
      condition     = startswith(var.ec2.instance_type, "t2.")
      error_message = "Instance type must be of t2 family."
    }
  }

  tags = {
    "Name": "company_web_server"
  }
}

resource "null_resource" "webserver_is_running_check" {
  lifecycle {
    postcondition {
      condition     = aws_instance.company_web_server.instance_state == "running"
      error_message = "Instance must be running."
    }
  }
}

In the above example, we ensure that this particular resource can only be created with a instance type of the t2 family. This could be a realistic situation for someone who wants to ensure that a specific kind of instances can only be used for a particular deployment, or for cost saving reasons.

We can also make use out of a null resource in instances where resources cannot interact with itself - We reference the aws_instance here to guarantee the state.


This post was created with contributions with other Rearc members, we would like to provide a special thanks to Eric Anderton, Will Kabrich, J Norment, and Neil Primmer.
Next steps

Ready to talk about your next project?

1

Tell us more about your custom needs.

2

We’ll get back to you, really fast

3

Kick-off meeting

Let's Talk