How to Implement Nested Loops with Count in Terraform for ECR Repository Policies


2 views

When working with AWS ECR repositories in Terraform, we often need to apply policies across multiple repositories and accounts. The core challenge is implementing nested iterations similar to bash's nested for-loops, but in Terraform's declarative syntax.

Terraform doesn't support traditional loops, but we can achieve similar functionality using count meta-argument and combinations of built-in functions. Here's how to properly structure the solution:

variable "list_of_allowed_accounts" {
  type    = list(string)
  default = ["111111111", "2222222"]
}

variable "list_of_images" {
  type    = list(string)
  default = ["alpine", "java", "jenkins"]
}

First, let's create a proper template file (ecr_policy.tpl):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCrossAccountPull",
      "Effect": "Allow",
      "Principal": {
        "AWS": "${account_id}"
      },
      "Action": [
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage"
      ]
    }
  ]
}

Here's the correct way to implement nested iteration in Terraform:

data "template_file" "ecr_policy_allowed_accounts" {
  count = length(var.list_of_images) * length(var.list_of_allowed_accounts)
  
  template = file("${path.module}/ecr_policy.tpl")

  vars = {
    account_id = var.list_of_allowed_accounts[count.index % length(var.list_of_allowed_accounts)]
    image_name = var.list_of_images[floor(count.index / length(var.list_of_allowed_accounts))]
  }
}

resource "aws_ecr_repository" "images" {
  count = length(var.list_of_images)
  name  = var.list_of_images[count.index]
}

resource "aws_ecr_repository_policy" "repo_policy_allowed_accounts" {
  count = length(var.list_of_images) * length(var.list_of_allowed_accounts)
  
  repository = aws_ecr_repository.images[
    floor(count.index / length(var.list_of_allowed_accounts))
  ].name
  
  policy = data.template_file.ecr_policy_allowed_accounts[count.index].rendered
}

For more complex scenarios, consider using for_each with maps:

locals {
  combinations = {
    for pair in setproduct(var.list_of_images, var.list_of_allowed_accounts) :
    "${pair[0]}-${pair[1]}" => {
      image   = pair[0]
      account = pair[1]
    }
  }
}

resource "aws_ecr_repository_policy" "repo_policy_foreach" {
  for_each = local.combinations
  
  repository = aws_ecr_repository.images[
    index(var.list_of_images, each.value.image)
  ].name
  
  policy = templatefile("${path.module}/ecr_policy.tpl", {
    account_id = each.value.account
  })
}

When implementing this pattern:

  • Be mindful of the 0.12+ syntax changes
  • Remember that count.index is zero-based
  • The modulo (%) operation helps cycle through the nested list
  • floor() helps with integer division for the outer loop

If you encounter errors:

  • Check for off-by-one errors in index calculations
  • Verify all variables are properly interpolated
  • Ensure your template file exists at the specified path
  • Validate the generated JSON policy against AWS requirements

When working with AWS ECR repositories, we often need to create cross-account access policies where multiple accounts need access to multiple container images. Terraform's declarative nature makes nested loops non-trivial compared to imperative languages.

We have two key variables to work with:

variable "list_of_allowed_accounts" {
  type    = list(string)
  default = ["111111111", "2222222"]
}

variable "list_of_images" {
  type    = list(string)
  default = ["alpine", "java", "jenkins"]
}

Instead of true nested loops, we use a flattened combination with setproduct:

locals {
  policy_combinations = [
    for pair in setproduct(var.list_of_images, var.list_of_allowed_accounts) : {
      image      = pair[0]
      account_id = pair[1]
    }
  ]
}

Here's the complete solution:

data "template_file" "ecr_policy_allowed_accounts" {
  for_each = { for idx, val in local.policy_combinations : idx => val }
  
  template = file("${path.module}/ecr_policy.tpl")
  
  vars = {
    account_id = each.value.account_id
    image_name = each.value.image
  }
}

resource "aws_ecr_repository" "images" {
  for_each = toset(var.list_of_images)
  name     = each.value
}

resource "aws_ecr_repository_policy" "repo_policy_allowed_accounts" {
  for_each = { for idx, val in local.policy_combinations : idx => val }
  
  repository = aws_ecr_repository.images[each.value.image].name
  policy     = data.template_file.ecr_policy_allowed_accounts[each.key].rendered
}

The policy template (ecr_policy.tpl) would look like:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CrossAccountAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::${account_id}:root"
      },
      "Action": [
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
        "ecr:BatchCheckLayerAvailability"
      ]
    }
  ]
}

For newer Terraform versions, dynamic blocks provide cleaner syntax:

resource "aws_ecr_repository_policy" "repo_policy" {
  for_each   = aws_ecr_repository.images
  
  repository = each.value.name
  
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      for account in var.list_of_allowed_accounts : {
        Sid       = "CrossAccountAccess-${account}"
        Effect    = "Allow"
        Principal = { AWS = "arn:aws:iam::${account}:root" }
        Action    = [
          "ecr:GetDownloadUrlForLayer",
          "ecr:BatchGetImage",
          "ecr:BatchCheckLayerAvailability"
        ]
      }
    ]
  })
}