Blog

How to Migrate from Terraform COUNT to FOR_EACH Without Breaking Infrastructure

Picture of Yoan Spasov
Yoan Spasov
DevOps & Cloud Engineer
02.10.2026
Reading time: 3 mins.
Last Updated: 02.10.2026

Table of Contents

When working with Terraform, the default meta-argument for creating resources should almost always be for_each – especially for infrastructure that is expected to live for a long time and evolve over time.

While COUNT may feel simpler initially, it introduces serious limitations as soon as infrastructure needs to scale, change shape, or support parallel environments. This becomes painfully obvious in real-world scenarios where flexibility and maintainability matter.

This article walks through a real migration from COUNT-based resources to FOR_EACH, highlighting why the change is worth the effort and what to watch out for along the way.

count

This issue surfaced while provisioning an additional Amazon OpenSearch Service domain from an existing Git repository.

The original implementation used COUNT-based resources with no module abstraction. While it worked for a single domain, extending it quickly became painful.

A common use case where this happens is when:

  • Master or data node instance types cannot be migrated in-place
  • A parallel OpenSearch cluster must be provisioned
  • Data needs to be migrated via snapshot and restore

With count, this kind of evolution becomes brittle and error-prone.

The starting point consisted of 33 individual variables, each with default values, defined separately in variables.tf.

Each variable was wired directly into individual resources, including:

aws_iam_service_linked_role
aws_kms_key
aws_security_group
aws_cloudwatch_log_group
aws_cloudwatch_log_resource_policy
aws_opensearch_domain
aws_opensearch_domain_policy
aws_s3_bucket
aws_s3_bucket_public_access_block
aws_s3_bucket_versioning
aws_s3_bucket_policy
aws_iam_role
aws_iam_policy
aws_iam_role_policy_attachment

This structure made it effectively impossible to provision a second OpenSearch domain in the same region without duplicating large parts of the codebase.

The first and most important step was consolidating all 33 variables into a single map of objects.

Instead of managing dozens of individual variables, everything was merged into one structured variable – still using the same defaults, but now allowing multiple domains to be defined declaratively.

This required updating resource definitions and introducing conditional logic for resource creation.

resource "aws_cloudwatch_log_resource_policy" "main" {
for_each = {
for k, v in var.es_domain :
k => v
if length(v.es_cloudwatch_log_types) > 0
}
}

In this example:

  • The CloudWatch Log Resource Policy is created only if es_cloudwatch_log_types is defined
  • If the field is not specified, it falls back to the default empty list
es_cloudwatch_log_types = optional(list(string), [])

If nothing is provided, the resource is not created – clean, predictable, and flexible.

One of the requirements for provisioning a new domain was the ability to:

  • Stop writes on the existing domain
  • Create a snapshot
  • Restore that snapshot to the new domain in the same AWS region

To support this, the following components were required:

  • An S3 bucket with an access policy allowing snapshot read/write
  • An IAM role assumable by the service principal es.amazonaws.com
  • Registration of the S3 bucket as a snapshot repository in both domains
PUT https://vpc-ew2-test-domain.eu-west-2.es.amazonaws.com/_snapshot/my-snapshots-repo
-d '{
"type": "s3",
"settings": {
"bucket": "opensearch-snapshots-ew2-test",
"region": "eu-west-2",
"role_arn": "arn:aws:iam::001122334455:role/opensearch-snapshot-role-ew2-test"
}
}'

Once registered:

  • A snapshot is created on Domain A
  • OpenSearch assumes the snapshot IAM role
  • Data is written to the S3 bucket
  • Domain B restores the snapshot from the same repository

This approach enables safe, controlled migrations between domains.

One critical step when switching from COUNT to FOR_EACH is informing Terraform that resource addresses have changed.

If this is not done, Terraform will attempt to destroy and recreate existing infrastructure.

Example:

moved {
from = aws_opensearch_domain.main[0]
to = aws_opensearch_domain.main["ew2-test"]
}
  • moved blocks accept only static strings
  • Loops or dynamic expressions are not supported
  • Large migrations must be applied incrementally, not all at once

While this makes the transition slower, it ensures state consistency and avoids downtime.

Switching from COUNT to FOR_EACH is rarely trivial – especially in mature Terraform codebases.

However, the long-term benefits far outweigh the short-term complexity:

  • Multiple OpenSearch domains per region
  • Cleaner, more expressive configuration
  • Easier snapshots, restores, and decommissioning
  • Infrastructure that evolves instead of fighting back

In the end, FOR_EACH enables Terraform to scale alongside your infrastructure – not hold it back.

Check our more of our blog posts here.

Newsletter for Tech experts

Signal, not noise -

straight to your inbox.

Join 12,000+ engineers and business leaders getting field notes on SRE, DevOps and cloud- native reliability.

Deep-dive tech blogs & case studies
Emerging tech, curated

Your Work Email

We respect your inbox. Read our Privecy Policy

More Posts

Managing secrets securely in Kubernetes is a critical challenge for modern cloud-native environments. Application credentials, certificates, private keys, and passwords must be handled in a way that is secure, auditable,...
Reading
As IPv6 adoption continues to grow, organizations running workloads on AWS increasingly need networking architectures that can support both IPv4 and IPv6 traffic. While AWS provides native IPv6 capabilities, integrating...
Reading
Get In Touch
ITGix provides you with expert consultancy and tailored DevOps services to accelerate your business growth.
Newsletter for
Tech Experts
Join 12,000+ business leaders and engineers who receive blogs, e-Books, and case studies on emerging technology.