
Managing Infrastructure Data with Terraform Outputs
Terraform outputs are one of those features that seem simple at first glance but become absolutely crucial as your infrastructure grows beyond a handful of resources. They serve as the glue between your Terraform modules and the rest of your infrastructure ecosystem, allowing you to extract important values like IP addresses, database connection strings, or load balancer endpoints and use them in other parts of your infrastructure or applications. This post will walk you through the ins and outs of Terraform outputs, from basic usage to advanced patterns, common gotchas you’ll inevitably encounter, and how to integrate outputs into larger infrastructure workflows.
How Terraform Outputs Work
Terraform outputs function as return values for your Terraform configurations. When you run terraform apply
, any defined outputs get displayed in your terminal and stored in the Terraform state file. Unlike variables that pass data into your configuration, outputs extract data from your resources after they’ve been created or updated.
The basic syntax is straightforward:
output "example_output" {
description = "Description of what this output represents"
value = resource.example.attribute
sensitive = false
}
Behind the scenes, Terraform evaluates outputs during the planning phase but only displays them after a successful apply. The values get stored in your state file and can be queried later using terraform output
commands or consumed by other Terraform configurations via remote state data sources.
One key thing to understand is that outputs are evaluated in dependency order. If your output references a resource that depends on other resources, Terraform ensures those dependencies are created first. This makes outputs reliable for passing data between different parts of your infrastructure stack.
Step-by-Step Implementation Guide
Let’s start with a practical example that demonstrates outputs in action. We’ll create a simple web server infrastructure and extract useful information:
# main.tf
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "main-vpc"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-west-2a"
map_public_ip_on_launch = true
tags = {
Name = "public-subnet"
}
}
resource "aws_instance" "web" {
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id
tags = {
Name = "web-server"
}
}
# outputs.tf
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "instance_public_ip" {
description = "Public IP address of the web server"
value = aws_instance.web.public_ip
}
output "instance_private_ip" {
description = "Private IP address of the web server"
value = aws_instance.web.private_ip
}
output "ssh_connection_command" {
description = "Command to SSH into the instance"
value = "ssh -i your-key.pem ec2-user@${aws_instance.web.public_ip}"
}
After running terraform apply
, you’ll see output similar to this:
Outputs:
instance_private_ip = "10.0.1.45"
instance_public_ip = "54.202.123.45"
ssh_connection_command = "ssh -i your-key.pem ec2-user@54.202.123.45"
vpc_id = "vpc-0123456789abcdef0"
For more complex scenarios, you can create outputs from computed values:
output "server_info" {
description = "Complete server information"
value = {
id = aws_instance.web.id
public_ip = aws_instance.web.public_ip
private_ip = aws_instance.web.private_ip
az = aws_instance.web.availability_zone
type = aws_instance.web.instance_type
}
}
output "database_connection_string" {
description = "Connection string for the database"
value = "postgresql://${aws_db_instance.main.username}:${var.db_password}@${aws_db_instance.main.endpoint}/${aws_db_instance.main.db_name}"
sensitive = true
}
To query outputs after deployment, use these commands:
# Show all outputs
terraform output
# Show specific output
terraform output instance_public_ip
# Show output in JSON format
terraform output -json
# Show sensitive outputs (requires explicit flag)
terraform output -json database_connection_string
Real-World Use Cases and Examples
Outputs shine in modular infrastructure setups. Here’s a common pattern where a networking module provides VPC information to an application module:
# modules/networking/main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
}
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
}
# modules/networking/outputs.tf
output "vpc_id" {
description = "VPC ID for use by other modules"
value = aws_vpc.main.id
}
output "private_subnet_ids" {
description = "List of private subnet IDs"
value = aws_subnet.private[*].id
}
output "vpc_cidr_block" {
description = "CIDR block of the VPC"
value = aws_vpc.main.cidr_block
}
Then consume these outputs in your application module:
# modules/application/main.tf
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = [var.vpc_cidr_block]
}
}
resource "aws_instance" "app" {
count = 2
ami = var.ami_id
instance_type = "t3.medium"
subnet_id = var.private_subnet_ids[count.index]
vpc_security_group_ids = [aws_security_group.app.id]
}
In your root configuration:
# main.tf
module "networking" {
source = "./modules/networking"
vpc_cidr = "10.0.0.0/16"
private_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
availability_zones = ["us-west-2a", "us-west-2b"]
}
module "application" {
source = "./modules/application"
vpc_id = module.networking.vpc_id
vpc_cidr_block = module.networking.vpc_cidr_block
private_subnet_ids = module.networking.private_subnet_ids
ami_id = "ami-0c02fb55956c7d316"
}
Another powerful use case is generating configuration files for external tools. For example, creating an Ansible inventory file:
output "ansible_inventory" {
description = "Ansible inventory file content"
value = templatefile("${path.module}/inventory.tpl", {
web_servers = aws_instance.web[*].public_ip
db_servers = aws_instance.db[*].private_ip
})
}
# inventory.tpl
[web]
%{ for ip in web_servers ~}
${ip}
%{ endfor ~}
[database]
%{ for ip in db_servers ~}
${ip}
%{ endfor ~}
Remote State and Cross-Stack Communication
One of the most valuable applications of outputs is sharing data between separate Terraform configurations using remote state. This pattern is essential for large organizations where different teams manage different parts of the infrastructure.
First, configure remote state in your foundation stack:
# foundation/main.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "foundation/terraform.tfstate"
region = "us-west-2"
}
}
resource "aws_vpc" "shared" {
cidr_block = "10.0.0.0/16"
}
resource "aws_route53_zone" "internal" {
name = "internal.company.com"
vpc {
vpc_id = aws_vpc.shared.id
}
}
# foundation/outputs.tf
output "shared_vpc_id" {
description = "Shared VPC ID for application stacks"
value = aws_vpc.shared.id
}
output "internal_zone_id" {
description = "Internal DNS zone ID"
value = aws_route53_zone.internal.zone_id
}
output "vpc_cidr" {
description = "VPC CIDR block"
value = aws_vpc.shared.cidr_block
}
Then consume these outputs in your application stack:
# application/main.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "application/terraform.tfstate"
region = "us-west-2"
}
}
data "terraform_remote_state" "foundation" {
backend = "s3"
config = {
bucket = "my-terraform-state"
key = "foundation/terraform.tfstate"
region = "us-west-2"
}
}
resource "aws_instance" "app" {
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.medium"
subnet_id = aws_subnet.app.id
}
resource "aws_subnet" "app" {
vpc_id = data.terraform_remote_state.foundation.outputs.shared_vpc_id
cidr_block = "10.0.10.0/24"
}
resource "aws_route53_record" "app" {
zone_id = data.terraform_remote_state.foundation.outputs.internal_zone_id
name = "app.internal.company.com"
type = "A"
ttl = 300
records = [aws_instance.app.private_ip]
}
Advanced Output Patterns and Techniques
For complex infrastructures, you’ll often need to manipulate and format output data. Terraform’s built-in functions make this straightforward:
# Advanced output examples
output "instance_map" {
description = "Map of instance names to IPs"
value = {
for instance in aws_instance.cluster :
instance.tags.Name => {
public_ip = instance.public_ip
private_ip = instance.private_ip
az = instance.availability_zone
}
}
}
output "load_balancer_targets" {
description = "Target group configuration for load balancer"
value = [
for instance in aws_instance.web : {
id = instance.id
ip = instance.private_ip
port = 80
}
]
}
output "environment_config" {
description = "Complete environment configuration"
value = {
vpc = {
id = aws_vpc.main.id
cidr = aws_vpc.main.cidr_block
dns_domain = aws_route53_zone.private.name
}
database = {
endpoint = aws_rds_instance.main.endpoint
port = aws_rds_instance.main.port
name = aws_rds_instance.main.db_name
}
cache = {
endpoint = aws_elasticache_cluster.main.cache_nodes[0].address
port = aws_elasticache_cluster.main.cache_nodes[0].port
}
}
}
output "kubeconfig" {
description = "Kubernetes configuration file"
value = templatefile("${path.module}/kubeconfig.tpl", {
cluster_name = aws_eks_cluster.main.name
cluster_endpoint = aws_eks_cluster.main.endpoint
cluster_ca = aws_eks_cluster.main.certificate_authority[0].data
})
sensitive = true
}
For outputs that might not exist conditionally, use conditional expressions:
output "database_endpoint" {
description = "Database endpoint if database is enabled"
value = var.enable_database ? aws_rds_instance.main[0].endpoint : null
}
output "ssl_certificate_arn" {
description = "SSL certificate ARN"
value = var.use_ssl ? aws_acm_certificate.main[0].arn : "SSL not enabled"
}
Integration with External Systems
Outputs become even more powerful when integrated with external systems. Here are some common integration patterns:
Generating configuration for monitoring tools:
output "prometheus_targets" {
description = "Prometheus scrape targets configuration"
value = yamlencode({
static_configs = [{
targets = [
for instance in aws_instance.web :
"${instance.private_ip}:9090"
]
labels = {
environment = var.environment
service = "web"
}
}]
})
}
output "grafana_dashboard_vars" {
description = "Variables for Grafana dashboard"
value = jsonencode({
environment = var.environment
region = var.aws_region
vpc_id = aws_vpc.main.id
instances = [
for instance in aws_instance.web : {
id = instance.id
name = instance.tags.Name
az = instance.availability_zone
}
]
})
}
Creating DNS records for service discovery:
output "consul_service_config" {
description = "Consul service discovery configuration"
value = {
services = [
for instance in aws_instance.web : {
name = "web-service"
address = instance.private_ip
port = 80
tags = ["web", var.environment]
checks = [{
http = "http://${instance.private_ip}/health"
interval = "10s"
}]
}
]
}
}
Common Pitfalls and Troubleshooting
Even experienced Terraform users run into output-related issues. Here are the most common problems and their solutions:
Circular Dependencies: This happens when you try to output a value that depends on a resource that references the output. Terraform will catch this during planning:
# This creates a circular dependency
resource "aws_security_group" "web" {
name = "web-sg"
ingress {
cidr_blocks = [data.terraform_remote_state.network.outputs.admin_cidr]
}
}
# If the network stack tries to output something that depends on this security group
# you'll get: "Cycle: output.admin_cidr, aws_security_group.web"
Solution: Break the cycle by restructuring your dependencies or using separate Terraform runs.
Sensitive Data Exposure: Outputs containing sensitive information will be displayed in plain text unless marked as sensitive:
# Wrong - password will be visible in terraform output
output "database_password" {
value = random_password.db_password.result
}
# Correct - password is hidden but still accessible programmatically
output "database_password" {
value = random_password.db_password.result
sensitive = true
}
Type Mismatches in Remote State: When consuming outputs from remote state, type mismatches can cause confusing errors:
# If the remote state outputs a string but you expect a list
resource "aws_instance" "web" {
count = length(data.terraform_remote_state.network.outputs.subnet_ids)
subnet_id = data.terraform_remote_state.network.outputs.subnet_ids[count.index]
vpc_security_group_ids = [data.terraform_remote_state.network.outputs.security_group_id]
}
Use the terraform console
command to inspect output types:
$ terraform console
> data.terraform_remote_state.network.outputs.subnet_ids
[
"subnet-12345",
"subnet-67890"
]
> type(data.terraform_remote_state.network.outputs.subnet_ids)
tuple([
string,
string,
])
Stale Remote State: Outputs from remote state might be stale if the source stack hasn’t been applied recently. Always verify that the source stack is up to date when debugging cross-stack issues.
Large Output Values: Terraform has limits on output size. For very large outputs (like complete configuration files), consider writing to a file and outputting the file path instead:
resource "local_file" "large_config" {
content = templatefile("${path.module}/large_config.tpl", {
instances = aws_instance.cluster[*]
# ... many variables
})
filename = "${path.module}/generated/cluster_config.json"
}
output "config_file_path" {
description = "Path to generated configuration file"
value = local_file.large_config.filename
}
Comparison with Alternative Approaches
While Terraform outputs are the standard way to extract infrastructure data, it’s worth understanding alternatives and when you might use them:
Approach | Best For | Pros | Cons |
---|---|---|---|
Terraform Outputs | Standard cross-stack communication | Native to Terraform, type-safe, stored in state | Requires remote state configuration, limited by state backend |
AWS Parameter Store | Cross-service communication in AWS | Encrypted storage, fine-grained permissions, versioned | AWS-specific, additional API calls required |
Consul KV Store | Multi-cloud service discovery | Real-time updates, distributed, TTL support | Additional infrastructure required, operational complexity |
External Data Sources | Querying existing infrastructure | Real-time data, works with any API | Performance impact, external dependencies |
Here’s an example using AWS Parameter Store as an alternative to outputs:
# Store infrastructure data in Parameter Store
resource "aws_ssm_parameter" "vpc_id" {
name = "/infrastructure/vpc/id"
type = "String"
value = aws_vpc.main.id
tags = {
ManagedBy = "terraform"
Stack = "foundation"
}
}
resource "aws_ssm_parameter" "database_endpoint" {
name = "/infrastructure/database/endpoint"
type = "SecureString"
value = aws_rds_instance.main.endpoint
tags = {
ManagedBy = "terraform"
Stack = "foundation"
}
}
# Consume in another stack
data "aws_ssm_parameter" "vpc_id" {
name = "/infrastructure/vpc/id"
}
resource "aws_subnet" "app" {
vpc_id = data.aws_ssm_parameter.vpc_id.value
cidr_block = "10.0.20.0/24"
}
Best Practices and Performance Considerations
Follow these practices to make your outputs maintainable and performant:
- Use descriptive names and descriptions: Your future self and teammates will thank you when debugging cross-stack issues.
- Group related outputs: Use objects to group related values instead of creating many individual outputs.
- Mark sensitive outputs appropriately: Even if data seems non-sensitive now, consider whether it might become sensitive later.
- Document output schemas: For complex object outputs, document the expected structure in comments or separate documentation.
- Version your output schemas: When changing output structure, consider backward compatibility or provide migration guidance.
# Good: Grouped, documented output
output "database_config" {
description = <<-EOT
Database configuration object containing:
- endpoint: RDS instance endpoint (string)
- port: Database port (number)
- name: Database name (string)
- security_group_id: Database security group ID (string)
EOT
value = {
endpoint = aws_rds_instance.main.endpoint
port = aws_rds_instance.main.port
name = aws_rds_instance.main.db_name
security_group_id = aws_security_group.db.id
}
}
# Avoid: Multiple scattered outputs
output "db_endpoint" { value = aws_rds_instance.main.endpoint }
output "db_port" { value = aws_rds_instance.main.port }
output "db_name" { value = aws_rds_instance.main.db_name }
output "db_sg" { value = aws_security_group.db.id }
For performance, be aware that outputs are evaluated on every plan/apply operation. Complex template functions or external data sources in outputs can slow down your Terraform runs. Consider pre-computing complex values in locals:
locals {
server_config = {
for instance in aws_instance.cluster :
instance.tags.Name => {
ip = instance.private_ip
az = instance.availability_zone
# Complex computation here
health_check_url = "https://${instance.private_ip}:8443/health?token=${random_password.health_token.result}"
}
}
}
output "server_config" {
description = "Pre-computed server configuration"
value = local.server_config
sensitive = true
}
Remember that outputs are a powerful tool for building maintainable, modular infrastructure. They're the foundation for creating reusable Terraform modules and enabling team collaboration on large infrastructure projects. The key is starting simple with basic outputs and gradually adopting more advanced patterns as your infrastructure grows in complexity.
For more detailed information about Terraform outputs, check the official Terraform documentation and explore the Terraform Registry for real-world examples in community modules.

This article incorporates information and material from various online sources. We acknowledge and appreciate the work of all original authors, publishers, and websites. While every effort has been made to appropriately credit the source material, any unintentional oversight or omission does not constitute a copyright infringement. All trademarks, logos, and images mentioned are the property of their respective owners. If you believe that any content used in this article infringes upon your copyright, please contact us immediately for review and prompt action.
This article is intended for informational and educational purposes only and does not infringe on the rights of the copyright owners. If any copyrighted material has been used without proper credit or in violation of copyright laws, it is unintentional and we will rectify it promptly upon notification. Please note that the republishing, redistribution, or reproduction of part or all of the contents in any form is prohibited without express written permission from the author and website owner. For permissions or further inquiries, please contact us.