Vincent De Borger DevOps engineer

Making Terraform code more modular

Published on Feb 17

Once you’ve worked with Terraform long enough, you’ll want to start digging into using modules and enabling/disabling the creation of resources using variables. You might have a big Git repository full of Terraform code which you want to refactor so it can be used for multiple projects, but not every project needs to use every resource in this repository.. How can we fix this? By making Terraform code truly modular ofcourse!

Let’s first take a look at how Terraform uses “count” and “for_each” meta-arguments to create resources more than once since we need to understand this functionality so we can use it further on. Meta-arguments are special Terraform arguments which are used for controlling how Terraform manages the resources it creates. These are not resource-specific (so you can use them for GCP/AWS/Kubernetes/.. providers) and can be used throughout all resource types.

The “count” meta-argument

So, let’s start with using “count”. This one’s pretty simple, it does exactly what the name suggests, it specifies the number of resources Terraform should created. For example, when you want to create 3 AWS EC2 instances, instead of defining the resource block 3 times in your Terraform code you can make use of the “count” meta-argument.

resource "aws_instance" "example" {
  count = 3
  
  ami           = "ami-0919d61dcc29ae777"
  instance_type = "t3.nano"
  
  tags = {
    Name = "instance-${count.index}"
  }
}

As you can see in the example above, there’s an object (called “count”, no surprises there) which is available when using the “count” meta-argument. This object has a single - yet very useful - attribute count.index. This attribute gets incremented every time a resource gets created. If we get back to the example, this code will create 3 EC2 instances with the following names: “instance-0”, “instance-1” and “instance-2”.

Handling attributes when using the “count” meta-argument

When a resource has a “count” meta-argument, you no longer can access the resource’s attributes using resource_type.resource_name.attribute_name. In order to access the attributes from a resource, we now have to add an index. So accessing an attribute (e.g. the public DNS name of an EC2 instance) of the example with the “count” meta-argument can be done using the following code.

output "instance_one" {
  value = aws_instance.example[0].public_dns
}

output "instance_two" {
  value = aws_instance.example[1].public_dns
}

output "instance_three" {
  value = aws_instance.example[2].public_dns
}

FYI: you can also make a list of an attribute from all the instances when you give an * as index.

The “for_each” meta-argument

Now that we’ve seen how multiple resources can be created using the “count” meta-argument, it’s time to take a look at the “for_each” meta-argument. Just like the “count” meta-argument, “for_each” allows you to create resources multiple times. The special thing about this meta-argument is that we can use it to loop through sets and maps.

locals {
  instance_names = ["web-server", "database", "redis-server"]
}

resource "aws_instance" "example" {
  for_each = toset(local.instance_names)
  
  ami           = "ami-0919d61dcc29ae777"
  instance_type = "t3.nano"
  
  tags = {
    Name = each.value
  }
}

The example above we’ll loop through a local variable called instance_list and create 3 AWS EC2 instances. For each iteration, Terraform will create an EC2 instance with the “Name” tag set to the value of the current iteration. This means we’ll get 3 EC2 instances called “web-server”, “database” and “redis-server”. Just like the “count” meta-argument, “for_each” has a object which we can use to retrieve more information about the current iteration. The only difference between the two is that when using “for_each”, there’s an object called “each” with 2 attributes “index” and “value” (compared to a “count” object with a single attribute). The “index” attribute works the same way as it does when you have a “count” meta-argument, the “value” attribute will contain the value of the element in the set/map.

Handling attributes when using the “for_each” meta-argument

Just like resource with the “count” meta-argument, we need an index for accessing a resource’s attributes. There’s one big difference though between the two is that with “count” the index is always an integer, with a for_each loop the index is the value of the element. That all sounds a bit complicated so let’s take a look at an example.

An attribute of a resource looped through a set of strings (["app-one", "app-two", "app-three"]) can be accessed by using one of the following pieces of code:

  • resource_type.resource_name["app-one"].attribute_name
  • resource_type.resource_name["app-two"].attribute_name
  • resource_type.resource_name["app-three"].attribute_name

So, when looking back at the example with the “for_each” meta-argument and try to access the public_dns attribute, we’ll need the following Terraform code.

output "instance_one" {
  value = aws_instance.example["web-server"].public_dns
}

output "instance_two" {
  value = aws_instance.example["database"].public_dns
}

output "instance_three" {
  value = aws_instance.example["redis-server"].public_dns
}

Making a resource optional

Now that we’ve learned how to work with the “count” and “for_each” meta-arguments, let’s use them to our advantage and start making optional resources. Making a resource optional is very useful when developing modules that get used in different environments. You might want to write a single module that creates a number of resources (e.g. EC2 instances) which are not required in every environment. This is a perfect situation where we can make use of optional resources. We’ll go through 3 different approaches, each with an increased level of complexity.

Simple if-statement using the “count” meta-argument

To get started, let’s look at the most simple way of enabling/disabling the creation of a resource in Terraform. To do this, we’ll combine the use of and “count” meta-argument (which we learned about in the beginning of this post) with an if-statement.

An if-statement in Terraform has a specific syntax comprised of 3 elements, an expression, a value which will be returned when the result of the expression is true and a value which will be returned when result of the the expression is false. This means we’ll get something along the lines of the following: <expression> ? <value if true> : <value if false>.

locals {
  enable_instance = true
}

resource "aws_instance" "example" {
  count = local.enable_instance ? 1 : 0
  
  ami           = "ami-0919d61dcc29ae777"
  instance_type = "t3.nano"
  
  tags = {
    Name = "example"
  }
}

In the example above we can enable/disable the creation of the “aws_instance” resource by setting the “enable_instance” local variable to true/false. The way how this works is by using an if-statement to set the value of “count” to either 1 or 0. When the value is 1, the resource will be created, when the value is 0, the resource will not be created.

Looping through a set of elements based on an if-statement

Now that we’ve covered how the creation of a simple resource can be enabled/disabled, it’s time to take a look at how we can do the same thing for looped resources. As we’ve seen in the beginning of this post, we can loop through a set of elements and use its values to created a resource in Terraform. In order to enable/disable the creation such resources, we’ll need to add an if-statement in the “for_each” meta-argument, just like we did for “count”. The only difference here is instead of defining 1 or 0 as the value, we’ll define a set of elements or an empty set.

locals {
  enable_instance = true
  instance_names  = ["web-server", "database", "redis-server"]
}

resource "aws_instance" "example" {
  for_each = local.enable_instance ? toset(local.instance_names) : []
  
  ami           = "ami-0919d61dcc29ae777"
  instance_type = "t3.nano"
  
  tags = {
    Name = each.value
  }
}

As we can see in the example, we have an “aws-instance” resource which has a “for_each” meta-argument. Like we saw before, this meta-argument lets us loop through a set of elements. The only difference is that we now have an if-statement. This if-statement lets will return the local.instance_names variable (which contains a set of strings) if the local.enable_instance is true. If it’s not true, the if-statement will return an empty set. When a “for_each” meta-argument receives an empty list, it will not create the resource.

Bonus: if-statement with for_each but make it ✨fancy✨

In the previous section, we explored how to enable/disable the creation of resources using the “count” or “for_each” meta-argument. However, there is more that we can accomplish with the “for_each” meta-argument. Suppose we require three EC2 instances with distinct names and instance types. Instead of inserting three resource blocks into the Terraform file, we can use a single resource block and iterate through a set of maps that store the necessary information. By incorporating the concepts covered in this article, we can conditionally generate resources while iterating through a set of maps. The resulting code is shown below.

locals {
  enable_instance = true
  instance_list  = [
    {
      name = "web-server"
      type = "t3.nano"
    },
    {
      name = "database"
      type = "t3.large"
    },
    {
      name = "redis-server"
      type = "t3.micro"
    },
  ]
}

resource "aws_instance" "example" {
  for_each = local.enable_instance ? { for instance in local.instance_list : instance.name => instance } : {}
  
  ami           = "ami-0919d61dcc29ae777"
  instance_type = each.value.type
  
  tags = {
    Name = each.value.name
  }
}

Another interesting thing when iterating through a set of maps is the way you can access the attributes. For that, we need to take a look at the “for_each” meta-argument. There’s some interesting stuff going on in order to get the iteration to work. We’ve got the following statement: { for instance in local.instance_list : instance.name => instance }, what this will do is convert the set to a map where each key is equal to the name parameter from each map and the value the map itself. In order to access the attributes of the created resources, you can now use the names of keys of the map as seen in the following example.

output "instance_one" {
  value = aws_instance.example["web-server"].public_dns
}

output "instance_two" {
  value = aws_instance.example["database"].public_dns
}

output "instance_three" {
  value = aws_instance.example["redis-server"].public_dns
}

Conclusion

In conclusion, the “for_each” and “count” meta-arguments in Terraform provide powerful tools for managing infrastructure resources. By using these meta-arguments, we can efficiently and dynamically create resources, even in complex scenarios that involve multiple instances with unique attributes. Furthermore, by combining these meta-arguments with other Terraform features, such as conditionals and interpolation, we can achieve even greater flexibility and automation in our infrastructure management processes.

More information is available in the following resources:

Similar posts


© Vincent De Borger 2024 — All rights reserved.