Terraform fails with "Can't access attributes on a list of objects." - Cannot access fields on resources created using for_each = toset(local.domains[*].domain_name)?

local.domains[*].domain_name is a list of domains, like www.example.com, that I need to convert to a set for use with for_each.

How can I iterate over the aws_apigatewayv2_domain_name resources individually to create aws_route53_record resources?

My current attempt results in the following error, which I don't understand (?):

╷
│ Error: Unsupported attribute
│
│   on ../modules/http-api-custom-domain-name/main.tf line 64, in resource "aws_route53_record" "api_gateway":
│   64:     name                   = aws_apigatewayv2_domain_name.api_gateway[each.value].domain_name_configuration.target_domain_name
│     ├────────────────
│     │ aws_apigatewayv2_domain_name.api_gateway is object with 1 attribute "bff-dev-partner.service-dev.dpt.lego.com"
│     │ each.value is "bff-dev-partner.service-dev.dpt.lego.com"
│
│ Can't access attributes on a list of objects. Did you mean to access attribute "target_domain_name" for a specific element of the list, or
│ across all elements of the list?

Instead of indexing aws_apigatewayv2_domain_name resources, I would rather iterate over them directly. Is this possible?

How can I fix the code, so that aws_route53_record and aws_api_gateway_base_path_mapping resources are created for each aws_apigatewayv2_domain_name?

Code:

resource "aws_apigatewayv2_domain_name" "api_gateway" {
  for_each = toset(local.domains[*].domain_name)

  domain_name = each.value

  domain_name_configuration {
    certificate_arn = var.certificate_arn == null ? aws_acm_certificate.cert[0].arn : var.certificate_arn
    endpoint_type   = "REGIONAL"
    security_policy = "TLS_1_2"
  }
}

resource "aws_route53_record" "api_gateway" {
  for_each = toset(local.domains[*].domain_name)

  name    = aws_apigatewayv2_domain_name.api_gateway[each.value].domain_name_configuration.domain_name
  type    = "A"
  zone_id = local.domain_to_hosted_zone[each.value]

  alias {
    name                   = aws_apigatewayv2_domain_name.api_gateway[each.value].domain_name_configuration.target_domain_name
    zone_id                = aws_apigatewayv2_domain_name.api_gateway[each.value].domain_name_configuration.hosted_zone_id
    evaluate_target_health = false
  }
}

resource "aws_api_gateway_base_path_mapping" "api_gateway" {
  for_each = aws_apigatewayv2_domain_name.api_gateway[*].domain_name

  api_id      = var.api_gateway_id
  stage_name  = var.api_gateway_stage
  domain_name = each.value
}

Errors:

│ Error: Unsupported attribute
│
│   on ../modules/http-api-custom-domain-name/main.tf line 59, in resource "aws_route53_record" "api_gateway":
│   59:   name    = aws_apigatewayv2_domain_name.api_gateway[each.value].domain_name_configuration.domain_name
│     ├────────────────
│     │ aws_apigatewayv2_domain_name.api_gateway is object with 1 attribute "bff-dev-partner.service-dev.dpt.lego.com"
│     │ each.value is "bff-dev-partner.service-dev.dpt.lego.com"
│
│ Can't access attributes on a list of objects. Did you mean to access an attribute for a specific element of the list, or across all
│ elements of the list?
╵
╷
│ Error: Unsupported attribute
│
│   on ../modules/http-api-custom-domain-name/main.tf line 64, in resource "aws_route53_record" "api_gateway":
│   64:     name                   = aws_apigatewayv2_domain_name.api_gateway[each.value].domain_name_configuration.target_domain_name
│     ├────────────────
│     │ aws_apigatewayv2_domain_name.api_gateway is object with 1 attribute "bff-dev-partner.service-dev.dpt.lego.com"
│     │ each.value is "bff-dev-partner.service-dev.dpt.lego.com"
│
│ Can't access attributes on a list of objects. Did you mean to access attribute "target_domain_name" for a specific element of the list, or
│ across all elements of the list?
╵
╷
│ Error: Unsupported attribute
│
│   on ../modules/http-api-custom-domain-name/main.tf line 65, in resource "aws_route53_record" "api_gateway":
│   65:     zone_id                = aws_apigatewayv2_domain_name.api_gateway[each.value].domain_name_configuration.hosted_zone_id
│     ├────────────────
│     │ aws_apigatewayv2_domain_name.api_gateway is object with 1 attribute "bff-dev-partner.service-dev.dpt.lego.com"
│     │ each.value is "bff-dev-partner.service-dev.dpt.lego.com"
│
│ Can't access attributes on a list of objects. Did you mean to access attribute "hosted_zone_id" for a specific element of the list, or
│ across all elements of the list?
╵
╷
│ Error: Unsupported attribute
│
│   on ../modules/http-api-custom-domain-name/main.tf line 71, in resource "aws_api_gateway_base_path_mapping" "api_gateway":
│   71:   for_each = aws_apigatewayv2_domain_name.api_gateway[*].domain_name
│
│ This object does not have an attribute named "domain_name".
1

There are 1 best solutions below

12
VonC On BEST ANSWER

When you use for_each to create multiple resources, Terraform treats these as a map of objects, not a list.

In your aws_route53_record and aws_api_gateway_base_path_mapping resources, you are trying to access attributes using a list syntax, but you should be using the map key (which is each.value in your case).

That means the syntax to access an attribute of an individual resource needs to be adjusted:

resource "aws_apigatewayv2_domain_name" "api_gateway" {
  for_each = toset(local.domains[*].domain_name)

  domain_name = each.value

  domain_name_configuration {
    certificate_arn = var.certificate_arn == null ? aws_acm_certificate.cert[0].arn : var.certificate_arn
    endpoint_type   = "REGIONAL"
    security_policy = "TLS_1_2"
  }
}

resource "aws_route53_record" "api_gateway" {
  for_each = aws_apigatewayv2_domain_name.api_gateway

  name    = each.value.domain_name_configuration.domain_name
  type    = "A"
  zone_id = local.domain_to_hosted_zone[each.key]

  alias {
    name                   = each.value.domain_name_configuration.target_domain_name
    zone_id                = each.value.domain_name_configuration.hosted_zone_id
    evaluate_target_health = false
  }
}

resource "aws_api_gateway_base_path_mapping" "api_gateway" {
  for_each = aws_apigatewayv2_domain_name.api_gateway

  api_id      = var.api_gateway_id
  stage_name  = var.api_gateway_stage
  domain_name = each.key
}

With this modification, you should no longer encounter the "Unsupported attribute" error. Each aws_route53_record and aws_api_gateway_base_path_mapping resource should be correctly associated with each aws_apigatewayv2_domain_name resource.


However, as noted by jordanm in the comments, it depends on how local.domains is defined:

  • If local.domains is a simple list of domain name strings, like ["www.example.com", "api.example.com"], the solution above would work as is. That is because toset(local.domains[*].domain_name) would correctly convert this list into a set of strings, which for_each can iterate over.

  • If local.domains is a list of maps or objects, such as [{"domain_name": "www.example.com"}, {"domain_name": "api.example.com"}], then the way you extract domain names for for_each would need to change. Instead of toset(local.domains[*].domain_name), you would need to use a more complex expression to extract the domain names from each map/object in the list.

  • If local.domains has a more complex or nested structure, the solution would need to be tailored to correctly navigate and extract the required information. For example, if local.domains includes additional nested attributes or varying levels of details, the method of extracting domain names for for_each would need to accommodate this complexity.


To detail the second case, when local.domains is a list of maps or objects, where each element contains a domain_name key, your Terraform configuration would need to extract these domain names differently for use with for_each.

Assuming local.domains is defined like this:

locals {
  domains = [
    { domain_name = "www.example.com" },
    { domain_name = "api.example.com" }
    // other domains
  ]
}

Your resources would be defined as follows:

  1. aws_apigatewayv2_domain_name: Use a for_each to iterate over a set of domain names extracted from local.domains.

    resource "aws_apigatewayv2_domain_name" "api_gateway" {
    for_each = { for d in local.domains : d.domain_name => d.domain_name }
    
    domain_name = each.value
    
    domain_name_configuration {
        certificate_arn = var.certificate_arn == null ? aws_acm_certificate.cert[0].arn : var.certificate_arn
        endpoint_type   = "REGIONAL"
        security_policy = "TLS_1_2"
    }
    }
    
  2. aws_route53_record: Referencing each.key and each.value based on the for_each used in the aws_apigatewayv2_domain_name resource.

    resource "aws_route53_record" "api_gateway" {
    for_each = aws_apigatewayv2_domain_name.api_gateway
    
    name    = each.value.domain_name_configuration.domain_name
    type    = "A"
    zone_id = local.domain_to_hosted_zone[each.key]
    
    alias {
        name                   = each.value.domain_name_configuration.target_domain_name
        zone_id                = each.value.domain_name_configuration.hosted_zone_id
        evaluate_target_health = false
    }
    }
    
  3. aws_api_gateway_base_path_mapping: Using each.key which represents the domain name in this setup.

    resource "aws_api_gateway_base_path_mapping" "api_gateway" {
    for_each = aws_apigatewayv2_domain_name.api_gateway
    
    api_id      = var.api_gateway_id
    stage_name  = var.api_gateway_stage
    domain_name = each.key
    }
    

The for_each in aws_apigatewayv2_domain_name is changed to create a map where keys and values are both domain names, extracted from the local.domains list of maps. That structure can then be used consistently in the other resources.


Yes, local.domains is defined as a list of object.

Why doesn't toset(local.domains[*].domain_name) work?
I expect this expression to return a set of domain names.
local.domain[*].domain_name iterates over each domain and extract the domain name?

The confusion arises from the syntax and behavior of Terraform's expressions, specifically when dealing with lists of objects and the splat expression [*].

If local.domains is a list of objects, like so:

locals {
  domains = [
    { domain_name = "www.example.com" },
    { domain_name = "api.example.com" }
    // other domains
  ]
}

The expression local.domains[*].domain_name attempts to use the splat expression to extract the domain_name attribute from each object in the list. However, this expression does not directly yield a simple list of strings; instead, it results in a complex list of lists due to how Terraform interprets the splat operator with objects.

The correct way to extract a list of strings (domain names in this case) from a list of objects in Terraform is to use a for expression:

[for domain in local.domains : domain.domain_name]

That for expression iterates over each object in local.domains and extracts the domain_name attribute, resulting in a flat list of strings.

Once you have a flat list of domain names, you can convert it to a set for use with for_each:

toset([for domain in local.domains : domain.domain_name])

That expression creates a set of domain names from local.domains, which can be used with for_each in your resource definitions.

So, when you tried to use toset(local.domains[*].domain_name), it did not work as expected because local.domains[*].domain_name did not produce a simple list of strings, but rather a more complex structure that is not directly compatible with toset(). The for expression resolves this issue by correctly flattening the structure into a simple list of strings.


For me, this looks a little weird: for_each = { for d in local.domains : d.domain_name => d.domain_name }. You are simulating a list by creating a map that maps a domain to itself? :)

Yes, your understanding is correct! The expression for_each = { for d in local.domains : d.domain_name => d.domain_name } does indeed create a map where each key is mapped to the same value. That might seem a bit odd at first glance, but it is a useful technique in Terraform for a couple of reasons:

  • Uniqueness of keys: In Terraform, when using for_each, the keys of the map need to be unique. By using the domain name as both the key and the value, you make sure each entry in the map is unique, which is a requirement for for_each to work correctly. That is particularly useful when the value (the domain name in this case) is already unique and encapsulates all the information needed for the iteration.

  • Ease of reference: By setting the value to be the same as the key, you can easily reference the domain name in your resource configurations. Within the resource block, each.key and each.value will both give you the domain name, making your code more intuitive and easier to understand.

  • Flexibility for expansion: That pattern also leaves room for easily expanding the configuration in the future. If you later need to include more information for each domain, you can adjust the map to include additional values while keeping the domain name as the key.

While it might seem redundant to map a value to itself, this approach is often used in Terraform to satisfy the unique key constraint of for_each and to make the code more flexible and maintainable.


If local.domains is a list of strings, then toset(local.domains[*].domain_name) would not work?
In that case, what is domain_name referring too? :)

If local.domains is a list of strings, the expression toset(local.domains[*].domain_name) would indeed not work as expected: when local.domains is a simple list of strings, like ["www.example.com", "api.example.com"], each element of the list is just a string, not an object or map. So, it does not have any attributes or named fields like domain_name to access.

The [*].domain_name part is a use of the splat operator, which is intended for use with lists of complex types (like objects or maps) to access a named attribute on each element. When applied to a list of simple strings, it does not make sense because there is no domain_name attribute on a string.

In the scenario where local.domains is a list of strings, converting it to a set would be straightforward with just toset(local.domains):

for_each = toset(local.domains)

In this case, local.domains itself is directly a list of domain names (strings), and toset() converts this list into a set for use with for_each. There is no need to use the splat operator since you are not accessing an attribute of a complex type, but rather directly using the string values in the list.


Do you know what is the difference between domain_name and target_domain_name for aws_apigatewayv2_domain_name?

Yes, in the context of the aws_apigatewayv2_domain_name resource in Terraform, domain_name and target_domain_name refer to two different aspects of the API Gateway custom domain setup

  • domain_name: That is the custom domain name that you are setting up for your API Gateway. It is the domain that clients will use to access your API. For example, if you have a custom domain like api.mycompany.com, this is what you would specify as the domain_name. That is essentially the public-facing URL that you want to map to your API Gateway.

  • target_domain_name: That attribute is part of the domain_name_configuration block within the aws_apigatewayv2_domain_name resource. It refers to the hostname that API Gateway assigns to your deployed API. That is the actual endpoint that API Gateway creates and maintains, and it is different from the custom domain name you provide. The target_domain_name is used internally by AWS to route requests to your API Gateway.

When setting up a custom domain in API Gateway, you typically point your custom domain (domain_name) to the AWS-generated target domain (target_domain_name) using a DNS record (like a CNAME or an Alias record in Route 53). That setup makes sure when users hit your custom domain, the request is routed correctly to your API Gateway's target domain, and from there, to your API's deployment stage.

In short: domain_name is the custom domain you want to use for your API, while target_domain_name is the AWS-generated endpoint to which your custom domain should point.


It still says each.value.domain_name_configuration is list of object with 1 element after applying your solution.

The error message indicating that each.value.domain_name_configuration is a list of objects with 1 element suggests that there is a mismatch between how the Terraform resource aws_apigatewayv2_domain_name is structured and how you are trying to access its attributes in the aws_route53_record and other related resources.

When you define a resource with for_each, each instance of that resource is a map where the key is defined by the for_each expression and the value is the resource itself. If aws_apigatewayv2_domain_name.api_gateway is defined with for_each, aws_apigatewayv2_domain_name.api_gateway[each.key] or aws_apigatewayv2_domain_name.api_gateway[each.value] will refer to individual instances of the aws_apigatewayv2_domain_name resource.

If your aws_route53_record resource configuration is:

resource "aws_route53_record" "api_gateway" {
  for_each = aws_apigatewayv2_domain_name.api_gateway

  name    = each.value.domain_name_configuration.domain_name
  type    = "A"
  zone_id = local.domain_to_hosted_zone[each.key]

  alias {
    name                   = each.value.domain_name_configuration.target_domain_name
    zone_id                = each.value.domain_name_configuration.hosted_zone_id
    evaluate_target_health = false
  }
}

Then make sure you are accessing the attributes of the aws_apigatewayv2_domain_name resource correctly. The resource might have a different structure than anticipated.

Review the Terraform documentation for aws_apigatewayv2_domain_name to confirm the structure of the resource and how its attributes should be accessed. The domain_name_configuration might be a list, and if so, you would need to access its first element, like each.value.domain_name_configuration[0].target_domain_name.

Use terraform state show to inspect the state of an individual aws_apigatewayv2_domain_name resource. That can give you a clear view of its structure and help you understand how to access its attributes.

Make sure your Terraform code is compatible with the version of the AWS provider you are using. Sometimes, attributes or their structures can change between versions.

And double-check the syntax and make sure there are no typos or incorrect references in your Terraform configuration.


Isn't there a way to debug Terraform to make it display the values of locals or variables at deployment time? It would make it so much simpler to troubleshoot errors.
I know of terraform console, but it's both cumbersome, and it's difficult to replicate real code.

As you mentioned, terraform console is a useful tool for experimenting with Terraform expressions. But if you want alternative, you might consider:

  • Output variables to print values during terraform apply or terraform plan. This is a simple yet effective way to see the values of variables, locals, or any expression. Define an output in your configuration like this:

    output "local_domains" {
       value = local.domains
    }
    

    After running terraform apply or terraform plan, Terraform will display the values of these outputs.

  • Debug logging that can be enabled by setting the TF_LOG environment variable. This can provide a lot of insight into what Terraform is doing, but the logs can be quite verbose. Use it like this:

    TF_LOG=DEBUG terraform apply
    

    Keep in mind that debug logs can contain sensitive information, so be cautious about where and how you share these logs.

  • Refactoring for clarity, breaking down complex expressions into simpler, more atomic parts can help in understanding and debugging. This might involve using more locals to hold intermediate values so you can output and inspect them easily.