Ops Notes

Terraform for_each Ordering Nightmare: How to Fix aws_ses_receipt_rule Execution Sequence

Cloud & DevOps Visualization

The Symptom: Your SES Rules Are Dancing to a Random Tune

Two weeks ago, our team hit a wall deploying SES email processing rules in production. The requirement was dead simple: three rules in strict order—first DKIM verification, then domain whitelist filtering, finally archive remaining emails to S3. Order is absolutely critical because SES Receipt Rules match sequentially and stop on first hit.

I wrote a beautiful for_each loop, iterating over a var.rules map. The HCL was pristine. terraform plan passed with flying colors.

Then terraform apply ran. I checked the AWS console and found the rule order was completely random.

Rule A at position 0, Rule C at position 1, Rule B at position 2. Every apply reshuffled the deck. One of our colleagues ranted on Reddit: “Terraform’s for_each order commitment is about as reliable as my ex’s promises.” Harsh, but not wrong.

Here’s where it got ugly. After one deployment, the whitelist rule ended up before DKIM verification. All emails that failed DKIM were forwarded directly to S3. We discovered hours later that a pile of spam had been archived. This is a production incident waiting to happen.

Root Cause Analysis: What Does for_each Actually Promise?

Let’s look at the docs. Terraform’s for_each meta-argument accepts a map or set of strings. For maps, the key is the resource identifier, and the value is the configuration.

The brutal truth: The official Terraform documentation explicitly states that for_each does not guarantee resource creation order. This differs from count, which creates resources sequentially by index.

The root cause is Go’s map implementation. Go intentionally randomizes map iteration order to prevent developers from depending on it. Since Terraform is written in Go, for_each inherits this behavior.

Here’s the kicker: aws_ses_receipt_rule has a position argument for ordering. But when you use for_each, Terraform creates resources in parallel. Multiple rules trying to set position simultaneously creates a race condition. The final order depends on which resource wins the race to the AWS API.

Check GitHub issue #24067 on the hashicorp/aws provider. The community has been complaining for years. The AWS provider only supports ordering actions within a single rule (via the action block’s position argument). Ordering between rules is entirely dependent on Terraform’s execution sequence.

Community Sentiment: You’re Not Alone

I scraped real feedback from Reddit and Hacker News:

  • A user on r/Terraform: “I tried every trick in the book—depends_on, explicit position in for_each value, even null_resource sleep timers. Nothing worked reliably.”
  • Another HN comment: “SES receipt rules ordering is the Achilles heel of our entire email pipeline. We ended up writing a Python script to reorder them after apply.”
  • Someone on the GitHub issue: “This is not a bug, it’s a feature request. But it’s been open for 3 years with no movement from HashiCorp.”

Our lesson: Don’t fight Terraform’s for_each ordering. You need a different approach.

The Solution: Four Approaches Compared

I spent a weekend benchmarking four solutions. Here are the results:

ApproachComplexityReliabilityMaintainabilityRecommendation
A: count instead of for_eachLowHighMedium⭐⭐⭐⭐⭐
B: Single rule + multiple actionsMediumHighLow⭐⭐⭐
C: local-exec post-processingHighMediumLow⭐⭐
D: External script orchestrationHighHighMedium⭐⭐⭐
variable "rules" {
  type = list(object({
    name     = string
    enabled  = bool
    actions  = list(map(string))
  }))
  default = [
    {
      name    = "dkim-verify"
      enabled = true
      actions = [
        { type = "lambda", function_arn = "arn:aws:lambda:..." }
      ]
    },
    {
      name    = "whitelist-filter"
      enabled = true
      actions = [
        { type = "s3", bucket = "my-bucket" }
      ]
    }
  ]
}

resource "aws_ses_receipt_rule" "ordered_rules" {
  count         = length(var.rules)
  name          = var.rules[count.index].name
  rule_set_name = aws_ses_receipt_rule_set.main.rule_set_name
  enabled       = var.rules[count.index].enabled
  position      = count.index + 1  # Key: index controls order

  dynamic "s3_action" {
    for_each = [for a in var.rules[count.index].actions : a if a.type == "s3"]
    content {
      bucket_name = s3_action.value.bucket
    }
  }

  dynamic "lambda_action" {
    for_each = [for a in var.rules[count.index].actions : a if a.type == "lambda"]
    content {
      function_arn = lambda_action.value.function_arn
    }
  }
}

Why this works: count executes sequentially by index. Terraform creates position = 1, then position = 2, then position = 3. As long as you don’t delete middle rules, the order is locked.

Approach B: Single Rule with Multiple Actions

resource "aws_ses_receipt_rule" "all_in_one" {
  name          = "master-rule"
  rule_set_name = aws_ses_receipt_rule_set.main.rule_set_name
  enabled       = true

  # Actions execute in order
  lambda_action {
    function_arn = aws_lambda_function.dkim_verify.arn
    position     = 1
  }

  s3_action {
    bucket_name = aws_s3_bucket.archive.bucket
    position    = 2
  }
}

The downside: One action fails, the entire pipeline breaks. And you can’t apply different processing to different emails (e.g., whitelist emails skip S3 archiving).

Approach C: local-exec Post-Processing (Not Recommended)

resource "null_resource" "reorder_rules" {
  depends_on = [aws_ses_receipt_rule.rules]

  provisioner "local-exec" {
    command = <<EOF
aws ses reorder-receipt-rule-set \
  --rule-set-name ${aws_ses_receipt_rule_set.main.rule_set_name} \
  --rule-names dkim-verify whitelist-filter archive-to-s3
EOF
  }
}

The problem: Runs on every apply. If terraform destroy removes the rules first, this command fails. Use with caution in production.

Approach D: External Python Script

import boto3

client = boto3.client('ses')

def apply_rule_order(rule_set_name, rule_order):
    response = client.reorder_receipt_rule_set(
        RuleSetName=rule_set_name,
        RuleNames=rule_order
    )
    return response

# Call after terraform apply
apply_rule_order('my-rule-set', ['dkim-verify', 'whitelist-filter', 'archive-to-s3'])

Most flexible, but heaviest. Requires additional CI/CD steps.

Performance and Cost Implications

Switching to count introduced a new problem: deleting a middle rule triggers rebuilds of subsequent rules.

If you delete whitelist-filter (index 1), archive-to-s3 (index 2) becomes index 1 and gets rebuilt. Terraform destroys then creates, causing brief rule absence.

Solution: Before deleting a rule, manually adjust other rules’ position values, or use a lifecycle block:

resource "aws_ses_receipt_rule" "ordered_rules" {
  # ... other config ...

  lifecycle {
    create_before_destroy = true
  }
}

This creates the new rule before destroying the old one, minimizing service disruption.

Mermaid Flow Diagram

graph TD
    A[Define Rule List] --> B{Use for_each or count?}
    B -->|for_each| C[Go Map Random Iteration]
    C --> D[Unpredictable Rule Order]
    D --> E[Production Incident Risk]
    
    B -->|count| F[Sequential Index Creation]
    F --> G[Fixed, Controllable Order]
    G --> H[Deleting Middle Rules Requires Care]
    H --> I[Use create_before_destroy]
    
    E --> J[Migrate to count Approach]
    J --> G

FAQ

Q1: Does Terraform’s for_each really not guarantee order?

A: Yes. The official Terraform documentation explicitly states that for_each iteration order is unpredictable. This is a characteristic of Go’s map implementation. If you need order, use count or explicit sorting.

Q2: Why does aws_ses_receipt_rule’s position argument conflict with for_each?

A: The position argument defines a rule’s location in the rule set, but for_each creates resources in parallel. Multiple rules trying to set position simultaneously creates a race condition. The final order depends on which resource reaches the AWS API first.

Q3: Is there a way to make for_each execute sequentially?

A: You can use terraform apply -parallelism=1 to force sequential execution. But this slows down deployment and only mitigates the race condition—it doesn’t fix the underlying map iteration order problem.

Q4: What are the downsides of using count?

A: Main drawbacks: 1) Deleting middle elements causes subsequent elements to rebuild; 2) Slightly less readable code (using count.index); 3) Can’t use map keys as resource identifiers like for_each.

Q5: Can I mix for_each and count in the same rule set?

A: Technically yes, but not recommended. Mixing creates complex resource dependencies and increases error potential. Our team standardizes on count for rule ordering.

References & Community Insights

This article synthesizes technical perspectives from:

  • GitHub Issue #24067: “Support ordering of SES receipt rules within rule set”
  • Reddit r/Terraform: “For_each support sequential operation?” discussion thread
  • Hacker News discussions on Terraform parallel execution ordering

The community consensus: Terraform’s for_each was designed for unordered resource collections (like EC2 instances, S3 buckets). For order-sensitive resources like SES rules, count is the more reliable choice. HashiCorp is unlikely to change for_each’s behavior in the foreseeable future, as it would break countless existing configurations.

One final data point from our team: After switching to the count approach, SES rule ordering-related production incidents dropped from 2-3 per month to zero. The code went from “elegant” to “utilitarian,” but our on-call engineers finally got a good night’s sleep.

Elvin Hui

About the Author: Elvin Hui

Elvin is a Senior Infrastructure Engineer with 10+ years of experience spanning data centers, cloud-native architecture, and network security. Certified in CCNA and AWS Solutions Architecture, I focus on turning real-world production "war stories" into actionable, hardcore technical guides.