DEV Community

Spacelift team for Spacelift

Posted on • Originally published at spacelift.io

How to Use Terraform Count Meta-Argument

Resource blocks are the building blocks of the Terraform language. They describe one or more infrastructure objects like virtual machines, gateways, load balancers, etc. A single resource block represents a single infrastructure object. But what if we want to create multiple near-identical infrastructure objects without having to copy-paste the resource block multiple times e.g., a fleet of EC2 instances or multiple users?

This is where the count meta-argument comes into the picture. Before jumping into understanding how to use it, let's quickly understand what meta arguments are.

What are meta-arguments in Terraform?

Terraform defines meta-arguments as arguments that can be used with every resource type to change the resource's behavior. Terraform supports the following meta-arguments:

  • depends_on
  • count
  • for_each
  • provider
  • lifecycle
  • provisioner

The scope of this article is limited to the count meta-argument.

What is the Terraform count meta-argument?

The Terraform count meta-argument simplifies the creation of multiple resource instances without having to repeat the same resource block multiple times. It can be used with both resource and module blocks. To use the count meta-argument, you need to specify the count argument within a block, which accepts a whole number value representing the number of instances you want to create.

How to use Terraform count?

Let's see the count meta-argument in action.

Note: Please note that all examples provided are simplified to illustrate the functionality of the count argument and may not always adhere to the best practice.

The following code snippet demonstrates a resource block responsible for generating a single EC2 instance.

resource "aws_instance" "backend_server" {
 ami           = "ami-07355fe79b493752d"
 instance_type = "t2.micro"
 tags = {
   Name = "backend-server"
 }
}
Enter fullscreen mode Exit fullscreen mode

Running the terraform plan command shows a plan to create a single instance of the aws_instance.backend_server resource.

# aws_instance.backend_server will be created
 + resource "aws_instance" "backend_server" {
     + ami                                  = "ami-07355fe79b493752d"
     + arn                                  = (known after apply)
     + associate_public_ip_address          = (known after apply)
     + availability_zone                    = (known after apply)
...

Enter fullscreen mode Exit fullscreen mode

Let's see how the plan changes with the introduction of the count meta-argument. We will set the count argument's value to 3 to create three instances of the backend_server.

resource "aws_instance" "backend_server" {
 ami           = "ami-07355fe79b493752d"
 instance_type = "t2.micro"
 count         = 3
 tags          = {
   Name = "backend-server"
 }
}
Enter fullscreen mode Exit fullscreen mode

Let's run the terraform plan command again to observe the changes.

# aws_instance.backend_server[0] will be created
 + resource "aws_instance" "backend_server" {
     + ami                                  = "ami-07355fe79b493752d"
     + arn                                  = (known after apply)
     + associate_public_ip_address          = (known after apply)
     + availability_zone                    = (known after apply)
...
# aws_instance.backend_server[1] will be created
 + resource "aws_instance" "backend_server" {
     + ami                                  = "ami-07355fe79b493752d"
     + arn                                  = (known after apply)
     + associate_public_ip_address          = (known after apply)
     + availability_zone                    = (known after apply)
...
# aws_instance.backend_server[2] will be created
 + resource "aws_instance" "backend-server" {
     + ami                                  = "ami-07355fe79b493752d"
     + arn                                  = (known after apply)
     + associate_public_ip_address          = (known after apply)
     + availability_zone                    = (known after apply)
Enter fullscreen mode Exit fullscreen mode

We can see that Terraform plans to create the expected three instances of the backend-server. Furthermore, it appends indices to the instance names to ensure unique identification.

However, after applying these changes using the terraform apply command, we notice that all the servers share the same name, "backend-server". This occurs because all instances were created with identical configurations.

terraform count index

What if we want to change the configurations between instances? Let's see how we can do that with the count object.

đź’ˇ You might also like:

Terraform count meta-argument - examples

We'll now go through some of the use case examples for the Terraform count meta-argument.

How to use Terraform count in resource blocks

Every Terraform resource block using the count meta-argument has the count object available in expressions.

The count object has a single attribute named index. As the name suggests, index is a sequential number for each instance starting from 0. We can use the index attribute as a part of the name to make them uniquely identifiable.

resource "aws_instance" "backend_server" {
 ami           = "ami-07355fe79b493752d"
 instance_type = "t2.micro"
 count         = 3
 tags          = {
   Name = "backend-server-${count.index}"
 }
}
Enter fullscreen mode Exit fullscreen mode

Let's check the terraform plan to see if the server names reflect the changes.

# aws_instance.backend_server[0] will be updated in-place
 ~ resource "aws_instance" "backend_server" {
       id                                   = "i-06a600d1cc7cb2015"
     ~ tags                                 = {
         ~ "Name" = "backend-server" -> "backend-server-0"
       }
...

 # aws_instance.backend_server[1] will be updated in-place
 ~ resource "aws_instance" "backend_server" {
       id                                   = "i-0b24597cd1d1b0cb1"
     ~ tags                                 = {
         ~ "Name" = "backend-server" -> "backend-server-1"
       }
...

 # aws_instance.backend_server[2] will be updated in-place
 ~ resource "aws_instance" "backend_server" {
       id                                   = "i-0226ea49c4220256a"
     ~ tags                                 = {
         ~ "Name" = "backend-server" -> "backend-server-2"
...
Plan: 0 to add, 3 to change, 0 to destroy.
Enter fullscreen mode Exit fullscreen mode

Great! The server names are now distinct.

Using the index attribute on its own may seem limited in its usability. However, it becomes incredibly powerful when we utilize it to reference external configurations.

Let's explore how we can refer to an external configuration with the index attribute.

Referring to an external configuration with an index

The index attribute can also be used to refer to a list of configurations defined as variables. As a simple example, we will define and refer to unique server names.

locals {
 server_names=["backend-service-a", "backend-service-b", "backend-service-c"]
}

resource "aws_instance" "backend_server" {
 ami           = "ami-07355fe79b493752d"
 instance_type = "t2.micro"
 count         = 3
 tags          = {
   Name = local.server_names[count.index]
 }
}
Enter fullscreen mode Exit fullscreen mode

We can, of course, customize more parameters of a resource by referring to a list of objects of configurations instead of using simple strings.

Referring to configurations outside is good. However, we are still hardcoding the value for the count.

Hardcoding values make the Terraform code less flexible and less maintainable. If you need to change the number of instances, you must manually modify the count value each time, increasing the likelihood of errors and making it harder to scale your infrastructure. Moreover, if the count values are scattered throughout the code, it becomes harder to track and manage changes. This can lead to difficulties in understanding and maintaining the configuration over time, especially as your infrastructure evolves.

How to use Terraform count with conditional expressions

The count argument supports using numeric expressions. For instance, we can change the resource block to derive the number of instances from the length of the list of configurations count = length(local.server_names).

locals {
 server_names=["backend-service-a", "backend-service-b", "backend-service-c"]
}

resource "aws_instance" "backend_server" {
 ami           = "ami-07355fe79b493752d"
 instance_type = "t2.micro"
 count         = length(local.server_names)
 tags          = {
   Name = local.server_names[count.index]
 }
}
Enter fullscreen mode Exit fullscreen mode

Running the terraform plan shows no changes since the length of the list is the same as the hard-coded value for count.

terraform output count

Is it possible to change the value of the count conditionally?

Being able to use numeric expressions opens up possibilities to play around with the number of instances conditionally. For instance, we can add an expression that returns a different value based on the instance_type.

locals {
  server_names = ["backend-service-a", "backend-service-b", "backend-service-c"]
}

variable "instance_type" {
  type  = string
  default = "t2.micro"
}

resource "aws_instance" "backend_server" {
  ami           = "ami-07355fe79b493752d"
  instance_type = var.instance_type
  count         = var.instance_type == "t2.micro" ? 3 : 1
  tags = {
    Name = local.server_names[count.index]
  }
}
Enter fullscreen mode Exit fullscreen mode

When the default value of the instance_type variable is set to t2.micro, the terraform plan remains the same.

terraform count

But when we change the value of instance_type to t2.medium, the terraform plan shows a new plan to reduce the number of instances as expected.

terraform count conditional

In the next section, we will learn how to use the count argument with modules.

How to use Terraform count in module blocks

Just the way we used the count argument with resource blocks, we can use it the same with Terraform modules.

# main.tf
module "server" {
 source = "./modules/server"
 count  = 2
}

# modules/server/main.tf
resource "aws_instance" "backend_server" {
 ami           = "ami-07355fe79b493752d"
 instance_type = "t2.micro"
 tags = {
   Name = "server"
 }
}
Enter fullscreen mode Exit fullscreen mode

Running the terraform plan shows the plan below.

# module.compute_servers[0].aws_instance.backend_server will be created
 + resource "aws_instance" "backend_server" {
     + ami                                  = "ami-07355fe79b493752d"
     + arn                                  = (known after apply)
...
# module.compute_servers[1].aws_instance.backend_server will be created
 + resource "aws_instance" "backend_server" {
     + ami                                  = "ami-07355fe79b493752d"
     + arn                                  = (known after apply
...
Plan: 2 to add, 0 to change, 0 to destroy.

Enter fullscreen mode Exit fullscreen mode

An interesting observation is that the index is now positioned after the module name rather than directly following the resource name. This shift in positioning is because Terraform creates instances of the entire module instead of individual resources. Essentially, this is equivalent to re-writing the resource block multiple times.

In previous examples, referring to an instance of a resource involved adding the index at the end of the resource name, such as module.aws_instance.backend_server[0]. However, with modules, to refer to a specific instance of a resource, we must first reference the module instance followed by the resource name, like module.compute_servers[0].aws_instance.backend_server. We will learn more about this later in the article.

We will now cover dynamic values and how to refer to other resources in a block using count.

How to use Terraform count with dynamic block

It is important to note that the value for the count argument must be known before Terraform executes any remote resource actions. The count value cannot reference any resource attributes that are only known after the configuration is applied. For example, count can't use a unique ID generated by the remote API when an object is created.

However, it is still possible to refer to other resource blocks and data resources within the count argument. In the upcoming section, we will explore how we can achieve this.

How to use Terraform count in data blocks and other resource blocks

Blocks using the count meta-argument can refer to other data and resource blocks to set the value of count the same way as referring to external configurations we saw earlier.

Referring to a data resource

In Terraform, data resources are utilized to read information from existing infrastructure and can be referenced within the count argument. For example, to create an EC2 instance in each subnet of an existing VPC, we can use the data resource aws_subnets and refer to it within the aws_instance block in the count meta argument.

locals {
  vpc_id = "vpc-0742ea90775a96859"
}

data "aws_subnets" "subnets" {
 filter {
   name   = "vpc-id"
   values = [local.vpc_id]
 }
}

resource "aws_instance" "backend_server" {
 ami           = "ami-07355fe79b493752d"
 instance_type = "t2.micro"
 count         = length(data.aws_subnets.subnets.ids)
 subnet_id     = data.aws_subnets.subnets.ids[count.index]
 tags          = {
   Name = "backend-server-${count.index}"
 }
}

output "subnets" {
 value = data.aws_subnets.subnets.ids
}
Enter fullscreen mode Exit fullscreen mode

Here, the aws_subnets data block returns a list of subnets matching the vpc-id filter and the count meta-argument refers to derive its value: length(data.aws_subnets.subnets.ids)

The terraform plan reflects that there are four subnets in the provided vpc.

terraform data count

Since we have four subnets, Terraform will automatically create a total of four EC2 machines, one per subnet.

# aws_instance.backend_server[0] will be created
 + resource "aws_instance" "backend_server" {
     + ami                                  = "ami-07355fe79b493752d"
     + arn                                  = (known after apply)
     + associate_public_ip_address          = (known after apply)
     + availability_zone                    = (known after apply)
...
# aws_instance.backend_server[1] will be created
 + resource "aws_instance" "backend_server" {
     + ami                                  = "ami-07355fe79b493752d"
     + arn                                  = (known after apply)
     + associate_public_ip_address          = (known after apply)
...
# aws_instance.backend_server[2] will be created
 + resource "aws_instance" "backend_server" {
     + ami                                  = "ami-07355fe79b493752d"
     + arn                                  = (known after apply)
     + associate_public_ip_address          = (known after apply)
...
# aws_instance.backend_server[3] will be created
 + resource "aws_instance" "backend_server" {
     + ami                                  = "ami-07355fe79b493752d"
     + arn                                  = (known after apply)
     + associate_public_ip_address          = (known after apply)
Enter fullscreen mode Exit fullscreen mode

Referring to a resource block

The count argument can just as easily refer to other resource blocks. For example, rather than referring to already existing subnets, we can create new subnets, each with an EC2 machine in it.

resource "aws_vpc" "demo_vpc" {
 cidr_block = "12.0.0.0/16"
 tags = {
   Name       = "demo-vpc"
 }
}

locals {
 cidr_blocks = [ "12.0.0.0/20", "12.0.16.0/20" ]
}

resource "aws_subnet" "demo_subnets" {
 vpc_id      = aws_vpc.demo_vpc.id
 count       = length(local.cidr_blocks)
 cidr_block  = local.cidr_blocks[count.index]
}

resource "aws_instance" "backend_server" {
 ami           = "ami-07355fe79b493752d"
 instance_type = "t2.micro"
 count         = length(aws_subnet.demo_subnets)
 subnet_id     = aws_subnet.demo_subnets[count.index].id
 tags          = {
   Name = "backend-server-${count.index}"
 }
}
Enter fullscreen mode Exit fullscreen mode

An interesting observation to make here is that both aws_subnet and aws_instance are using the count argument. The aws_subnet resource refers to the cidr_blocks variable while the aws_instance resource refers to aws_subnet.

Pay attention to how we reference the id attribute of the demo_subnets instances: subnet_id = aws_subnet.demo_subnets[count.index].id. Notice that we refer to the instance instead of the resource block. We will learn about the differences between these two in the next section.

Resource block vs resource instances

We observed earlier that in the absence of the count argument Terraform uses the regular resource name to refer to the infrastructure object. However, with the count argument, Terraform uses indices to refer to specific instances. This is because when the count meta argument is used, Terraform distinguishes between the resource block and the instances of the resource.

Terraform count limitations

While the count meta argument is a powerful feature, there are some limitations and considerations:

  1. Limited dynamic scaling: The count argument is evaluated during the planning phase, and the resources are provisioned based on that count. If you need dynamic scaling (e.g., adjusting the count based on runtime conditions), Terraform's count might not be the most suitable option.
  2. Limited Logic: The count feature primarily relies on simple numeric values. If you need more complex logic or conditional creation of resources, you might need to consider other features like Terraform for_each.
  3. Unintended changes based on ordering: When using count, the resource instances are identified by an index. Modifying an element anywhere in between the list causes unintended changes for all subsequent elements.

Let's explore how using count meta-argument can introduce unintended changes based on ordering.

In the example we discussed earlier, let's try adding a new server somewhere in the middle of the server_names list. The expectation would be that the terraform plan reflects a plan to add a single new resource.

locals {
  server_names = ["backend-service-a", "backend-service-a1", "backend-service-b", "backend-service-c"]
}

resource "aws_instance" "backend_server" {
  ami           = "ami-07355fe79b493752d"
  instance_type = "t2.micro"
  count         = length(local.server_names)
  tags = {
    Name = local.server_names[count.index]
  }
}
Enter fullscreen mode Exit fullscreen mode

terraform count list

We can see that instead of just adding a single new resource, Terraform plans to additionally change two existing resources. This behavior occurs because instances are identified by their index. If an element is modified anywhere in the list, it triggers changes for all subsequent elements, which is unintended.

In such cases, using the for_each meta argument is more suitable. By utilizing for_each, we can define a map or set of key-value pairs to uniquely identify each instance. This allows us to modify individual elements without affecting the others.

Rewriting the same example using for_each, reflects the expected behavior without unintended changes to other resources.

locals {
  server_names = ["backend-service-a", "backend-service-a1", "backend-service-b", "backend-service-c"]
}

resource "aws_instance" "backend_server" {
  ami           = "ami-07355fe79b493752d"
  instance_type = "t2.micro"
  for_each      = toset(local.server_names)
  tags = {
    Name = each.value
  }
}
Enter fullscreen mode Exit fullscreen mode

terraform count function

With the utilization of for_each, every instance is uniquely identified through a key, and this identification is independent of the order. In the case of a list, the key and value are the same.

for_each vs count

When should you opt for for_each instead of count? If your resource instances aren't identical, choosing the for_each meta-argument is preferable, as it grants greater control over how objects change.

Learn more about the differences between count and for_each meta-arguments.

Terraform count best practices

When using the count meta-argument, it's essential to follow best practices to ensure that your infrastructure code is maintainable, scalable, and avoids potential pitfalls. Here are some best practices for using the count meta-argument:

1. Avoid hardcoding values

Avoid hardcoding values related to count whenever possible. Instead, use variables to make your configurations more flexible and adaptable to changes.

2. Use input variables for dynamic configuration

Leverage input variables to dynamically configure the count value. This allows for more flexible and parameterized configurations, making it easier to adapt to different environments.

3. Consider using for_each for non-identical instances

If you are dealing with non-identical instances, consider using the for_each meta-argument instead of count. This provides more control and flexibility in managing resources individually.

4. Understand the dependencies

Understand the dependencies between resources when using the count meta-argument to avoid unexpected issues during resource creation or deletion.

4. Review terraform plans

Before applying changes, always review the terraform plan to understand the impact of count-related modifications. This helps catch potential issues before they affect your infrastructure.

By following these best practices, you can ensure that your usage of the count meta-argument aligns with Terraform's best practices, resulting in maintainable and scalable infrastructure.

Key points

The count meta-argument is a powerful argument to create multiple instances without having to repeat any code. It brings efficiency and scalability to Terraform configurations. However, the count meta-argument is not a one-size-fits-all solution. While it excels in scenarios where identical instances are needed, it may fall short in situations requiring more nuanced control over individual resources. Overall, when used judiciously, the count argument enhances the flexibility and maintainability of IaC.

We encourage you also to explore how Spacelift makes it easy to work with Terraform.

If you need any help managing your Terraform infrastructure, building more complex workflows based on Terraform, and managing AWS credentials per run, instead of using a static pair on your local machine, Spacelift is a fantastic tool for this. It supports Git workflows, policy as code, programmatic configuration, context sharing, drift detection, and many more great features right out of the box.

You can check it for free, by creating a trial account.

Written by Omkar Birade

Top comments (1)

Collapse
 
grapplingdev profile image
Dan @roadmap.sh

Awesome article!

We just posted a Terraform roadmap over at roadmap.sh/terraform!