Mastering Terraform Outputs

Terraform output is a key feature used to get and display important information about what is getting provisioned, debugging your terraform code and also to pass information from one terraform module to another. While in most cases a simple output block suffice the need to display and pass information about the infrastructure getting provisioned. Sometime that is not enough, and we might need to manipulate and format the terraform output based on our requirements.

For example, you might be using a count or for_each block to provision multiple resources with the same configuration block. In this case how do you output the information of multiple resources provisioned using the count or for_each in your terraform output? Or your terraform output is giving a complex data structures like a map of objects or a nested list and you want to query the output and display a specific information.

This is where advanced Terraform output patterns come into play. By utilizing Terraform’s powerful iteration and transformation functions, you can construct sophisticated data structures like map of maps or list of lists, transforming your outputs from simple strings into a robust, machine-readable interfaces for your infrastructure.

The Challenge of Simple Outputs

Let us consider a basic scenario. If you deploy a single EC2 instance, outputting its public IP is straightforward:

resource "aws_instance" "my_single_instance" {
  ami           = "ami-053b0d53c279e6041" # Example AMI (Ubuntu 22.04 LTS HVM)
  instance_type = "t2.micro"
  tags = {
    Name = "MySingleInstance"
  }
}

output "instance_public_ip" {
  description = "The public IP address of the EC2 instance."
  value       = aws_instance.my_single_instance.public_ip
}

This works for a single instance. But what if you deploy multiple instances using count or for_each and need to expose their IPs, IDs, and DNS names in a structured way? A simple output like aws_instance.my_instances.*.public_ip would return a flattened list of IPs, losing the association with other instance attributes.

This is where we need to learn how to format terraform outputs and get a specific item.

Key Terraform Constructs for Advanced Outputs

To build complex output structures, we will primarily rely on a few powerful Terraform language constructs:

  1. for Expressions: Used for transforming and filtering collections (lists and maps). This is analogous to map or filter operations in many programming languages.
  2. for_each (Implicit with Resources/Modules): When you use for_each to create multiple instances of a resource or module, Terraform natively provides a map of objects (e.g., aws_instance.example["key"]) that is perfect for iterating over.
  3. zipmap(): Useful for combining two lists into a map, where elements from the first list become keys and elements from the second become values.
  4. merge(): For combining multiple maps into a single map.
  5. try(): A function for safely accessing attributes that might be optional or might not exist in certain scenarios, preventing errors.
  6. Type Constraints: Explicitly defining the type of your outputs (e.g., map(object(...)), list(object(...))) significantly improves readability, maintainability, and provides early validation.

Let us dive deep into some practical patterns regarding terraform outputs.

Outputting a Map of Objects (e.g., EC2 Instances with Details)

This is a common requirement: you have deployed several identical (or similarly configured) resources, and you want to output a clear, key-value structured representation of their essential attributes.

Problem: We have deployed multiple EC2 instances using for_each, and we want an output that maps each instance’s name to an object containing its ID, public IP, and public DNS name.

Solution: Use a for expression to create a map comprehension from the aws_instance resource, leveraging its for_each generated map.

# main.tf
resource "aws_instance" "app_servers" {
  for_each      = toset(["web-01", "web-02", "api-01"])
  ami           = "ami-053b0d53c279e6041" # Example AMI (Ubuntu 22.04 LTS HVM)
  instance_type = "t2.micro"
  tags = {
    Name = each.key
  }
}

output "server_details" {
  description = "A map containing details for each deployed server."
  # Define the explicit type for clarity and validation
  value = {
    for name, instance in aws_instance.app_servers : name => {
      id        = instance.id
      public_ip = instance.public_ip
      public_dns = instance.public_dns
    }
  }
  # Recommended: Add a type constraint for robustness
  # type = map(object({
  #   id         = string
  #   public_ip  = string
  #   public_dns = string
  # }))
}

Explanation:

  • for name, instance in aws_instance.app_servers: This iterates over the aws_instance.app_servers collection, which is a map created by for_each. name will be the key (“web-01”, “web-02”, “api-01”), and instance will be the full object representing each aws_instance resource.
  • name => { ... }: This defines the key-value pair for the new output map. The key will be the instance name, and the value will be an object.
  • id = instance.id, public_ip = instance.public_ip, public_dns = instance.public_dns: These lines extract specific attributes from each instance object and assign them to keys within the new output object.

Example Output (truncated):

{
  "server_details": {
    "api-01": {
      "id": "i-0abcdef1234567890",
      "public_dns": "ec2-3-88-123-45.compute-1.amazonaws.com",
      "public_ip": "3.88.123.45"
    },
    "web-01": {
      "id": "i-0fedcba9876543210",
      "public_dns": "ec2-54-123-67-89.compute-1.amazonaws.com",
      "public_ip": "54.123.67.89"
    },
    "web-02": {
      "id": "i-0123456789abcdef0",
      "public_dns": "ec2-18-234-90-12.compute-1.amazonaws.com",
      "public_ip": "18.234.90.12"
    }
  }
}

Outputting a List of Objects (e.g., Database Endpoints)

Sometimes, you need a simple ordered collection of structured data, perhaps for a configuration file or another automated process.

Problem: We have deployed multiple database instances, and we want a list containing the endpoint address and port for each.

Solution: Use a for expression to create a list comprehension.

# main.tf
resource "aws_db_instance" "app_databases" {
  for_each             = toset(["orders", "users"])
  engine               = "mysql"
  engine_version       = "8.0"
  instance_class       = "db.t3.micro"
  allocated_storage    = 20
  db_name              = each.key
  username             = "admin"
  password             = "password" # In production, use secrets management!
  skip_final_snapshot  = true
  # Add other required arguments like VPC security groups etc.
}

output "database_endpoints" {
  description = "A list of database endpoint details."
  value = [
    for db_name, db_instance in aws_db_instance.app_databases : {
      name    = db_name
      address = db_instance.address
      port    = db_instance.port
    }
  ]
  # Recommended: Add a type constraint
  # type = list(object({
  #   name    = string
  #   address = string
  #   port    = number
  # }))
}

Explanation:

  • [ for db_name, db_instance in aws_db_instance.app_databases : { ... } ]: This iterates through the aws_db_instance.app_databases map (generated by for_each) and constructs a list.
  • { name = db_name, address = db_instance.address, port = db_instance.port }: For each database instance, an object is created with its name, address, and port, which then becomes an element in the resulting list.

Example Output (truncated):

{
  "database_endpoints": [
    {
      "address": "orders.xxxxxx.us-east-1.rds.amazonaws.com",
      "name": "orders",
      "port": 3306
    },
    {
      "address": "users.yyyyyy.us-east-1.rds.amazonaws.com",
      "name": "users",
      "port": 3306
    }
  ]
}

Combining Outputs from Multiple Resource Types (e.g., Network Components)

Sometimes, a single logical component in your architecture is built from several different Terraform resources. You might want to expose a holistic view of this component as a single structured output.

Problem: We have a VPC, several subnets, and a security group. We want an output that bundles the VPC ID, a map of subnet IDs (keyed by name), and the security group ID.

Solution: Define each resource, and then use merge() along with for expressions to aggregate the data into a single object in the output.

# main.tf
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
  tags = {
    Name = "MyVPC"
  }
}

resource "aws_subnet" "public" {
  for_each          = toset(["a", "b"])
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${index(toset(["a", "b"]), each.key)}.0/24"
  availability_zone = "us-east-1${each.key}"
  tags = {
    Name = "PublicSubnet-${each.key}"
  }
}

resource "aws_security_group" "web_access" {
  name        = "web_access_sg"
  description = "Allow web access"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

output "network_summary" {
  description = "A comprehensive summary of core network components."
  value = {
    vpc_id         = aws_vpc.main.id
    public_subnets = { for k, v in aws_subnet.public : v.tags.Name => v.id }
    security_groups = {
      web_access_sg_id = aws_security_group.web_access.id
      # Add other security groups here if needed
    }
  }
  # Recommended: Add a type constraint
  # type = object({
  #   vpc_id         = string
  #   public_subnets = map(string)
  #   security_groups = map(string)
  # })
}

Explanation:

  • We create a single object in the network_summary output.
  • vpc_id = aws_vpc.main.id: Directly assigns the VPC ID.
  • public_subnets = { for k, v in aws_subnet.public : v.tags.Name => v.id }: This uses a for expression to create a map where keys are the subnet names (from tags) and values are their IDs. This demonstrates how to transform the for_each output into a more user-friendly map.
  • security_groups = { web_access_sg_id = aws_security_group.web_access.id }: A nested map to categorize different security groups.

Example Output (truncated):

{
  "network_summary": {
    "public_subnets": {
      "PublicSubnet-a": "subnet-0abcdef1234567890",
      "PublicSubnet-b": "subnet-0fedcba9876543210"
    },
    "security_groups": {
      "web_access_sg_id": "sg-0123456789abcdef0"
    },
    "vpc_id": "vpc-01234567890abcde"
  }
}

Handling Optional or Missing Attributes with try()

Sometimes, an attribute might not exist for all instances of a resource (e.g., public_ip for an EC2 instance in a private subnet, or for an instance that is stopped). Directly accessing such an attribute when it is null can cause Terraform to error. The try() function provides a graceful way to handle this.

Problem: We are outputting EC2 instance details, but some instances might not have a public IP address. We want to output an empty string or a default value instead of an error.

Solution: Use try(expression, default_value) within your output.

# main.tf (assuming instances defined as in Pattern 1)
resource "aws_instance" "app_servers" {
  for_each      = toset(["web-01", "web-02", "api-01-private"]) # Added a private instance
  ami           = "ami-053b0d53c279e6041"
  instance_type = "t2.micro"
  tags = {
    Name = each.key
  }
  # Example: For 'api-01-private', assume it is in a private subnet with no public IP
  # This part is conceptual for demonstration of try(), actual subnet logic omitted
  associate_public_ip_address = each.key == "api-01-private" ? false : true
}

output "server_details_robust" {
  description = "A map containing details for each deployed server, robust to missing public IPs."
  value = {
    for name, instance in aws_instance.app_servers : name => {
      id        = instance.id
      # Use try() to provide a fallback value if public_ip is null
      public_ip = try(instance.public_ip, "N/A - No Public IP")
      # Use try() for public_dns as well
      public_dns = try(instance.public_dns, "N/A - No Public DNS")
    }
  }
  type = map(object({
    id         = string
    public_ip  = string
    public_dns = string
  }))
}

Explanation:

  • try(instance.public_ip, "N/A - No Public IP"): If instance.public_ip evaluates to null or causes an error (e.g., if the attribute does not exist for the resource), try() will return "N/A - No Public IP" instead. Otherwise, it returns the actual public_ip.
  • This makes your outputs more robust and prevents terraform apply failures due to missing data from the state.

Example Output (truncated):

{
  "server_details_robust": {
    "api-01-private": {
      "id": "i-0abcdef1234567890",
      "public_dns": "N/A - No Public DNS",
      "public_ip": "N/A - No Public IP"
    },
    "web-01": {
      "id": "i-0fedcba9876543210",
      "public_dns": "ec2-54-123-67-89.compute-1.amazonaws.com",
      "public_ip": "54.123.67.89"
    }
    // ... other instances
  }
}

Best Practices for Advanced Outputs

To ensure your advanced outputs are maintainable, readable, and reliable:

  1. Always Specify Type Constraints:
    • Using type = map(object({ ... })) or type = list(object({ ... })) provides validation and makes your outputs’ structure explicit. Terraform will check your output’s value against this type, catching errors early.
  2. Prioritize Readability:
    • While for expressions can be powerful, avoid excessively long or deeply nested ones. Break them down into locals variables if they become hard to read.
    • Use meaningful variable names within your for loops.
  3. Document Your Outputs:
    • Always use the description argument in your output blocks. Explain what the output represents, its structure, and how it should be consumed.
  4. Use sensitive When Appropriate:
    • If your output contains sensitive information (e.g., database passwords, API keys), mark it as sensitive = true. This prevents Terraform from displaying its value in the console.
  5. Think About Consumers:
    • Design your outputs with the consumer in mind. Will another Terraform module consume this? A CI/CD pipeline? A human? Tailor the structure and verbosity to their needs.
  6. Modular Design:
    • Advanced outputs are particularly useful when composing modules. A module can expose a structured set of data about the resources it provisions, allowing other modules or root configurations to consume that data easily.

Conclusion

Moving beyond simple string outputs in Terraform is an important step towards building more robust, maintainable, and interconnected infrastructure as code. By mastering for expressions, try(), and effectively utilizing structured data types, you can create powerful interfaces that not only describe your infrastructure but also facilitate its integration into broader automation workflows.

Start experimenting with these patterns in your own Terraform projects. You will quickly discover how they streamline data flow, improve clarity, and unlock new possibilities for managing your cloud environments. Happy Terraforming!

Author

Debjeet Bhowmik

Experienced Cloud & DevOps Engineer with hands-on experience in AWS, GCP, Terraform, Ansible, ELK, Docker, Git, GitLab, Python, PowerShell, Shell, and theoretical knowledge on Azure, Kubernetes & Jenkins.
In my free time, I write blogs on ckdbtech.com

Leave a Comment