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.

The Problem with count in Long-Lived Infrastructure
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 Initial State: Too Many Variables, Too Little Flexibility
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.
Refactoring to FOR_EACH: The Core Design Change
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.
Example: Conditional Resource Creation with FOR_EACH
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.
Snapshot and Restore Between Parallel OpenSearch Domains
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:
Required Infrastructure
- 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
Registering the Snapshot Repository
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.
Handling Resource Address Changes with moved
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"]
}
Important Limitations
- 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.
Final Thoughts: Why FOR_EACH Is Worth It
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.

