Output Contract #
When a Terraform module is used by one team, changing an output is a small job. But when a module is used by ten teams at once, deleting or renaming a single output can break every configuration that depends on it. Outputs aren’t just a technical mechanism — they’re a contract between the module author and its callers. Understanding how to design this contract well is the skill that separates modules that get reused quickly from modules that get abandoned quickly.
flowchart LR
A["📦 VPC Module\nProvider Output"] -->|Contract: vpc_id, subnet_ids| B["📦 Compute Module\nConsumer"]
A -->|Contract: vpc_id, subnet_ids| C["📦 Database Module\nConsumer"]
D["⚠️ Remove output\nvpc_id"] -.->|BREAKS| B
D -.->|BREAKS| C
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#10b981,stroke:#059669,color:#fff
style C fill:#10b981,stroke:#059669,color:#fff
style D fill:#ef4444,stroke:#dc2626,color:#fffOutputs as a Public API #
The best analogy for module outputs is the public API of a library. Once that API is in use, changing it requires thinking about backward compatibility.
A GOOD CONTRACT:
The "vpc" module promises:
- output vpc_id → always exists, string type
- output public_subnet_ids → always exists, list(string) type
- output private_subnet_ids → always exists, list(string) type
Callers can rely on this contract:
module.vpc.vpc_id → SAFE to use
module.vpc.public_subnet_ids → SAFE to use
BREAKING CHANGES TO AVOID:
- Removing an existing output
- Changing an output's type (string → list)
- Renaming an output
- Changing an output object's structure in a non-backward-compatible way
flowchart TD
subgraph ROOT["Root Module"]
R_MAIN["main.tf"]
end
subgraph CHILD["Child Module: networking"]
C_VPC["resource aws_vpc"]
C_SUB["resource aws_subnet"]
C_OUT_VPC["output vpc_id"]
C_OUT_SUB["output subnet_ids"]
end
C_VPC --> C_OUT_VPC
C_SUB --> C_OUT_SUB
C_OUT_VPC -->|"module.networking.vpc_id"| R_MAIN
C_OUT_SUB -->|"module.networking.subnet_ids"| R_MAIN
style ROOT fill:#e8f5e9,stroke:#2e7d32
style CHILD fill:#e3f2fd,stroke:#1565c0Good Output Design Principles #
# PRINCIPLE 1: Descriptive, consistent output names
# ANTI-PATTERN: Ambiguous names
output "id" {
value = aws_vpc.main.id # ID of what? VPC? Subnet? Instance?
}
output "ip" {
value = aws_instance.web.public_ip # Public or private IP?
}
# CORRECT: Names explicit about the resource and attribute
output "vpc_id" {
value = aws_vpc.main.id
}
output "web_instance_public_ip" {
value = aws_instance.web.public_ip
}
# PRINCIPLE 2: Informative descriptions — explain WHAT the output is used FOR
# ANTI-PATTERN: A description that repeats the name
output "vpc_id" {
description = "The VPC ID" # ✗ Adds no information
value = aws_vpc.main.id
}
# CORRECT: A description that explains the usage context
output "vpc_id" {
description = "ID of the main VPC — used by the compute and database modules to place resources in the right network."
value = aws_vpc.main.id
}
# PRINCIPLE 3: Type consistency — don't mix types for similar outputs
# ANTI-PATTERN: Inconsistent output types
output "public_subnet_id" {
value = aws_subnet.public[0].id # String — only one subnet
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id # List — can be many subnets
}
# Callers must know when to use a string and when to use a list
# CORRECT: Consistently use a list even for a single element
output "public_subnet_ids" {
value = aws_subnet.public[*].id # Always a list
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id # Always a list
}
Grouping Related Outputs #
Instead of many separate outputs for one resource, structured outputs are easier to use and more resilient to breaking changes.
# ANTI-PATTERN: Overly granular outputs
output "lb_dns_name" {
value = aws_lb.main.dns_name
}
output "lb_arn" {
value = aws_lb.main.arn
}
output "lb_zone_id" {
value = aws_lb.main.zone_id
}
output "lb_security_group_id" {
value = aws_security_group.lb.id
}
# 4 separate outputs for one resource — vulnerable to breaking changes if any is added/removed
# CORRECT: Structured output in a single object
output "load_balancer" {
description = "Load balancer information — DNS, ARN, zone ID, and security group"
value = {
dns_name = aws_lb.main.dns_name
arn = aws_lb.main.arn
zone_id = aws_lb.main.zone_id
security_group_id = aws_security_group.lb.id
}
}
# Easier to extend — add new fields without breaking existing usage
# module.app.load_balancer.dns_name → still valid
Adding Outputs Without Breaking Changes #
Adding a new output is always safe. What’s dangerous is changing or removing an existing output.
# SAFE: Adding a new output
# Existing callers are unaffected, new callers can use the new output
# Module version 1.0:
output "vpc_id" { value = aws_vpc.main.id }
output "private_subnet_ids" { value = aws_subnet.private[*].id }
# Module version 1.1 (backward compatible — only additions):
output "vpc_id" { value = aws_vpc.main.id } # Still present
output "private_subnet_ids" { value = aws_subnet.private[*].id } # Still present
output "public_subnet_ids" { value = aws_subnet.public[*].id } # NEW — safe
output "nat_gateway_ips" { value = aws_eip.nat[*].public_ip } # NEW — safe
# DANGEROUS: Renaming or changing the type of an existing output
# Every caller using this output will error
# DON'T do this without a major version bump:
# Before: output "subnet_ids" { value = ... }
# After: output "private_subnet_ids" { value = ... } ← rename = breaking change
# If you must rename, provide a transition period:
output "subnet_ids" {
value = aws_subnet.private[*].id
description = "DEPRECATED: Use private_subnet_ids. Will be removed in v3.0."
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
description = "List of private subnet IDs"
}
Outputs for Integration with External Systems #
Outputs aren’t only for fellow Terraform configurations — often their values need to be consumed by other systems.
# Outputs designed for consumption by external systems
# should be in an easily parsable format
output "connection_strings" {
description = "Connection strings for various components — used by the deployment pipeline"
sensitive = true # Contains credentials
value = {
database = "postgresql://${aws_db_instance.main.username}@${aws_db_instance.main.endpoint}/${aws_db_instance.main.db_name}"
redis = "redis://${aws_elasticache_cluster.main.cache_nodes[0].address}:${aws_elasticache_cluster.main.cache_nodes[0].port}"
}
}
output "kubernetes_config" {
description = "Configuration for kubectl setup — used by the CI/CD pipeline"
value = {
cluster_name = aws_eks_cluster.main.name
cluster_endpoint = aws_eks_cluster.main.endpoint
cluster_region = var.aws_region
}
}
# Consuming outputs from a CI/CD pipeline
DB_ENDPOINT=$(terraform output -raw database_endpoint)
CLUSTER_NAME=$(terraform output -json kubernetes_config | jq -r '.cluster_name')
aws eks update-kubeconfig \
--name "$CLUSTER_NAME" \
--region "$(terraform output -json kubernetes_config | jq -r '.cluster_region')"
Versioning Output Contracts #
As a module evolves, the output contract needs to be managed carefully so as not to break callers.
# VERSION 1.0: Initial outputs
output "db_endpoint" {
description = "Database endpoint"
value = aws_db_instance.main.endpoint
}
# VERSION 1.5: Add a new output, mark the old one deprecated
output "db_endpoint" {
description = "DEPRECATED: Use database.primary_endpoint"
value = aws_db_instance.main.endpoint
}
output "database" {
description = "Database connection details"
value = {
primary_endpoint = aws_db_instance.main.endpoint
port = aws_db_instance.main.port
engine = aws_db_instance.main.engine
}
}
# VERSION 2.0: Remove the old output
# output "db_endpoint" { ... } # REMOVED
# Consumers MUST migrate to the "database" output
flowchart LR
A["v1.0\ndb_endpoint"] --> B["v1.5\ndb_endpoint (deprecated)\n+ database (new)"]
B --> C["v2.0\ndatabase only"]
style A fill:#e3f2fd,stroke:#1565c0
style B fill:#fff3e0,stroke:#e65100
style C fill:#e8f5e9,stroke:#2e7d32Output Documentation Standards #
Well-documented outputs are the key to a maintainable module.
# Output documentation standards:
# 1. Always provide a description
output "vpc_id" {
description = "ID of the VPC created by this module"
value = aws_vpc.main.id
}
# 2. Describe the format for complex outputs
output "database_endpoint" {
description = "Database endpoint. Format: host:port"
value = "${aws_db_instance.main.address}:${aws_db_instance.main.port}"
}
# 3. Mark sensitive if it contains secrets
output "admin_password" {
description = "Database admin password. MUST be stored in a secret manager."
value = random_password.admin.result
sensitive = true
}
# 4. Use object outputs for grouping
output "networking" {
description = "All networking information from the module"
value = {
vpc_id = aws_vpc.main.id
public_subnet_ids = aws_subnet.public[*].id
private_subnet_ids = aws_subnet.private[*].id
nat_gateway_ips = aws_nat_gateway.main[*].public_ip
}
}
# Generate documentation from outputs
terraform output -json | jq 'to_entries[] | {
name: .key,
type: .value.type,
sensitive: .value.sensitive
}'
Output Migration Strategy #
# When changing an output contract, follow these steps:
# 1. Add the new output (backward-compatible)
# 2. Mark the old output as deprecated (in the description)
# 3. Give consumers time to migrate (1-2 sprints)
# 4. Monitor: who is still using the old output?
# 5. Remove the old output in the next major version
flowchart TD
A["Output v1.0\ndb_endpoint"] --> B["v1.5: Add a new output\ndb_endpoint (deprecated)\ndatabase (new)"]
B --> C["Consumers migrate\nto the database output"]
C --> D["v2.0: Remove\ndb_endpoint"]
style A fill:#e3f2fd,stroke:#1565c0
style B fill:#fff3e0,stroke:#e65100
style C fill:#e3f2fd,stroke:#1565c0
style D fill:#ffebee,stroke:#c62828Summary #
- Outputs are a public contract — once others use them, changes require backward compatibility considerations.
- Explicit output names —
vpc_id,web_instance_public_ipare better than ambiguousidorip.- Descriptions that explain usage, not repeat the name — “used by the compute module to…” is far more useful.
- Structured outputs as objects are easier to extend without breaking changes than many separate per-attribute outputs.
- Adding outputs is always safe, removing or renaming is always breaking — if you must, provide a transition period with a deprecation notice.
- Mark
sensitive = truefor outputs containing credentials or other sensitive data.