10 Essential Tips for Writing Effective Terraform Modules 

10 Essential Tips for Writing Effective Terraform Modules 

February 14, 2024
Get tips and best practices from Develeap’s experts in your inbox

Terraform, a key tool in modern DevOps, stands at the forefront of infrastructure as code (IaC), enabling teams to efficiently manage and provision their cloud resources. One of Terraform’s fundamental assets lies in its modules, which serve as reusable templates for various infrastructure components. While there is an abundance of pre-built modules available in the Terraform community, the unique needs of specific projects often necessitate the development of custom modules. 

This article aims to give you some helpful tips and practical advice for building your own Terraform modules, helping you to quickly create efficient, reusable and CI/CD friendly modules. 

This article aims to provide valuable insights and practical tips for developing such custom Terraform modules, with a focus on enhancing their functionality within CI/CD pipelines.

1. Eliminate the need for Terraform Destroy

Integrate a create boolean variable in your modules, serving as an on-off switch for resource creation. This feature is particularly useful for managing resources in CI/CD pipelines, allowing you to control resource deployment without the need for terraform destroy. For enhanced granularity, include create_* variables for specific resources within the module. Here’s how you can implement it:

variable "create" {
  description = "Flag to control resource creation"
  type        = bool
  default     = false
}

variable "create_subnet" {
  description = "Flag to control the creation of a subnet"
  type        = bool
  default     = false
}

resource "aws_vpc" "example_vpc" {
  count = var.create ? 1 : 0
  // VPC configurations
}

resource "aws_subnet" "example_subnet" {
  count = var.create && var.create_subnet ? 1 : 0
  // Subnet configurations
}

This setup allows you to easily manage resources: to remove a resource, simply set create_* = false and run terraform apply. No need for complex destroy commands.
I recommend setting all create and create_* variables to false by default. This practice ensures users must consciously decide to enable resource creation, significantly reducing the risk of accidental deployments. Another key benefit of defaulting to false is that just by reviewing the code, you can immediately identify which resources are actively deployed (true) and which are not (false or absent from the code). This clarity provides a straightforward way to understand and track the current state of your infrastructure, improving overall management and oversight.

2. Default values 

Building on the concept from tip number 1, where we set create = false to prevent unnecessary resource creation, we need to ensure Terraform doesn’t prompt for inputs when no resources are being created. Therefore, always set default values for all variables. However, avoid assigning actual values as defaults (like t2.micro for ec2_type). Opt for placeholders like “”, [], {} or null. This practice, again, requires users to make conscious decisions about the resources they are creating. Of course, sometimes it does make sense to have a default value (for instance, for resources that have attributes with a default value, it usually makes sense to keep this default), so apply practical judgment. 

variable "instance_type" {
  description = "Type of EC2 instance"
  type = string
  default = null
}

3. Documentation

Documentation is not just about user-friendliness; it’s a key element in ensuring the long-term sustainability and maintainability of your Terraform modules. When it seems like everything is fresh in your mind, it might be tempting to skip documentation. However, remember that your future self – and anyone else who might use your module – will greatly appreciate the extra effort you put into clear and comprehensive documentation now.

When documenting variables, describe each one’s purpose, usage, and necessity in various contexts. Clearly indicate whether a variable is required, optional, or conditional – in relation to other variables. For example, if a variable is needed only when a specific feature like create_* is enabled, this dependency should be explicitly stated.

4. Terraform Functions

Terraform provides a variety of functions for efficient infrastructure configuration, from string manipulation to conditional logic. Familiarising yourself with these functions can significantly enhance your Terraform proficiency. For an extensive overview of these capabilities, refer to the official Terraform Language Functions documentation.

Here I want to talk about the try function, which is a handy tool for handling optional resources without errors. This is especially useful for optional resources that might not be created, avoiding unnecessary script failures. Here’s an example:

output "iam_role_name" {
  description = "Name of IAM role"
  vaule       = try(aws_iam_role.this.name, null)
}

5. The Ternary Function Limitation

As you delve into making your Terraform modules more dynamic, you’ll likely encounter a common and frustrating error: “The true and false result expressions must have consistent types.” This issue arises when using Terraform’s ternary operator (condition ? true : false), where the true and false outcomes must be of the same type. In Terraform, “type” refers not only to the broad category like “list” but also to the exact structure of the objects within the list. For example, the following will trigger an error:

dynamic_value = var.some_conditional ? {
  example1 = value1
  example2 = ["value2", "value3"]
} : {
  example1 = "value4"
}

This limitation can be quite bothersome, but there’s a simple workaround. Consider this approach:

dynamic_value = [{
  example1 = "value1"
  example2 = ["value2", "value3"]
}, {
  example1 = "string"
}][var.some_conditional ? 1 : 0]

What’s happening here? Instead of directly using a ternary operator, we create a tuple (which can have any kind of different objects and types in it) and use the ternary operator to select the appropriate index from that tuple. This method overcomes the type consistency requirement, allowing for more complex and dynamic configurations in your modules. And a heartfelt thank you to Manu Magalhães whom I learned this trick from.

6. Use ‘Random’ for Unique Resource Naming

The random_string resource is a great way to ensure unique names for your resources.  This approach prevents naming conflicts and removes the burden of manually selecting unique names each time the module is used. 

To enhance this approach and offer even more flexibility, consider combining the random string with a variable like var.name_prefix. This allows users to append a consistent prefix to the random string, making resource identification easier while maintaining uniqueness.

Here’s an example of how you can implement this:

variable "name_prefix" {
  description = "Optional prefix for resource names"
  type        = string
  default     = "default-"
}

resource "random_string" "identifier" {
  length  = 8
  special = false
}

resource "aws_instance" "example" {
  name [ "${var.name_prefix}${random_string.identifier.result}"
  // additional configurations
}

7. Avoid hardcoding

Avoid hardcoding as much as possible. Even in scenarios where it might seem that certain values will never change, it’s important to remember that “100% certain” often turns out to be just 99%, and you will find yourself modifying and redeploying the module just to change a value that was initially hardcoded.

Even if you are confident that a particular value will be used in all use cases, it’s advisable to define it as a variable. Set the value you anticipate as the default, but allow the possibility of change.

8. Keep Your Modules DRY (Don’t Repeat Yourself)

Embracing the DRY principle in Terraform module development is crucial for efficient and sustainable infrastructure as code.

Suppose you’ve created multiple modules for different AWS resources, and in each, you find yourself defining IAM roles, policies, and attachments. This repetition is not only time-consuming but can lead to inconsistencies and maintenance challenges. A more efficient approach is to create a dedicated IAM module (or utilize an existing one) and reference it wherever needed.

Beyond saving time and reducing code redundancy, the DRY approach significantly simplifies maintenance. When updates or changes are required – whether due to resource changes or Terraform updates – you only need to update a single module. Then, simply update the version reference in other modules that use it. This method ensures consistency across your infrastructure and saves you from the tedious task of updating each module individually.

9. ‘Be Large’

Consider this scenario: You’re creating an AWS EC2 instance in your Terraform module, which has around 45 possible argument references. Initially, you might set up the module with just the basics:

resource "aws_instance" "web" {
  count         = var.create && var.create_ec2 ? 1 : 0 // You learned something already!
  ami           = var.ami
  instance_type = var.instance_type
}

This setup is quick and satisfies immediate needs. However, suppose the next day, you need to adjust the volume size of this EC2 instance. You realize this option wasn’t included in your module. To accommodate this, you now have to go back and modify the module:

resource "aws_instance" "web" {
  count         = var.create && var.create_ec2 ? 1 : 0
  ami           = var.ami
  instance_type = var.instance_type
  volume_size   = var.volume_size

To avoid the continuous cycle of updating and versioning your module for each new requirement, it’s essential to “be large” when defining resources in your Terraform modules. This means incorporating as many relevant arguments as possible. It might seem like extra work initially and could result in more variables for users to understand (hence the importance of good documentation), but it ultimately makes your module more dynamic and versatile. You’ll thank yourself later for this foresight and planning, as it saves significant time and effort in the long run.

10. Extend the ‘Be Large’ Philosophy to Nested Modules

When your Terraform module calls other modules, they come with their own set of configurable variables. In line with the Be Large philosophy, it’s beneficial to expose as many of these variables as possible in your own module. This enhances the flexibility and adaptability of your module. This can be easily achieved by visiting the source code of the nested module and copying its variable.tf file into your own modules variable.tf. After copying, review these variables carefully. Adjust them, remove unnecessary ones, and set appropriate defaults as per your specific needs.

Conclusion

So there you have it – these tips are designed to elevate your skills in developing Terraform modules. I hope these insights prove useful in your development process and contribute positively to your projects. Remember, the quality of your Terraform modules plays a significant role in the effectiveness of your infrastructure management. A well-crafted module can significantly enhance operational efficiency.
Happy Terraforming!

We’re Hiring!
Develeap is looking for talented DevOps engineers who want to make a difference in the world.