Write Once, Deploy Everywhere: Mastering Terraform's Expression Toolkit
Source: Dev.to
It’s Day 10 of the AWS Challenge, and today I’m exploring the features that transform Terraform from a simple declarative tool into a flexible, intelligent configuration powerhouse. Yesterday’s lifecycle meta‑arguments gave us surgical control; today’s expressions give us surgical precision with maximum reusability.
We’ll look at Conditional Expressions, Dynamic Blocks, and Splat Expressions—the trio that eliminates code duplication and makes your infrastructure truly adaptable across environments.
Conditional Expressions
Syntax
condition ? true_value : false_value
Terraform evaluates the condition. If true, it uses the first value; otherwise it uses the second.
Real‑World Example
resource "aws_instance" "app_server" {
ami = var.ami
instance_type = var.environment == "production" ? "t3.large" : "t3.micro"
monitoring = var.environment == "production" ? true : false
tags = {
Name = "${var.environment}-app-server"
Environment = var.environment
CostCenter = var.environment == "production" ? "prod-ops" : "dev-team"
}
}
Production gets beefier instances with monitoring enabled, while dev environments stay lean—all from a single configuration.
Use Cases
- Selecting instance sizes based on environment
- Enabling expensive features (e.g., enhanced monitoring) only in production
- Choosing different AMIs per region
- Setting resource counts (scale production higher than dev)
- Applying environment‑specific security policies
Pro Tip: If you find yourself chaining multiple ternary operators, break the logic out into locals blocks for readability.
Dynamic Blocks
Syntax
dynamic "block_name" {
for_each = var.collection
content {
# Configuration using block_name.value
}
}
Note:
for_eachon a resource creates multiple resources. Dynamic blocks create multiple nested blocks within a single resource.
Real‑World Example: Security Group Rules
variable "ingress_rules" {
type = list(object({
port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
default = [
{
port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTP access"
},
{
port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS access"
},
{
port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
description = "SSH access"
}
]
}
resource "aws_security_group" "app_sg" {
name = "app-security-group"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
description = ingress.value.description
}
}
}
Why This Is Brilliant
- Add a new rule by updating the variable—no code changes needed.
- Provide environment‑specific rule sets by passing different lists.
- Disable all external access with an empty list.
- Audit open ports by inspecting a single variable.
Other Powerful Use Cases
- Attaching multiple EBS volumes to EC2 instances
- Building IAM policy statements
- Populating route‑table routes
- Defining CloudWatch alarm actions
- Any nested block that repeats with variation
Important: Dynamic blocks work only for nested blocks within a resource. To create multiple resources, use count or for_each on the resource itself.
Splat Expressions
Syntax
resource_list[*].attribute_name
Real‑World Example
resource "aws_instance" "web_servers" {
count = 3
ami = var.ami_id
instance_type = "t3.micro"
tags = {
Name = "web-server-${count.index}"
}
}
# Output all instance IDs in one line
output "instance_ids" {
value = aws_instance.web_servers[*].id
}
# Output all private IPs
output "private_ips" {
value = aws_instance.web_servers[*].private_ip
}
# Use those IPs in another resource
resource "aws_lb_target_group_attachment" "web" {
count = length(aws_instance.web_servers)
target_group_arn = aws_lb_target_group.web.arn
target_id = aws_instance.web_servers[*].id[count.index]
}
Without splat expressions you’d need a loop or repetitive code; with them it’s a single elegant line.
Common Use Cases
- Extracting IDs from multiple EC2 instances for downstream resources
- Collecting subnet IDs from a VPC data source
- Getting security‑group IDs to attach to resources
- Gathering ARNs for IAM policies
- Building lists for outputs and dependencies
Pro Tip: Combine splat with functions like join() for extra power:
output "all_ips_comma_separated" {
value = join(",", aws_instance.web_servers[*].private_ip)
}
Putting It All Together
Below is a production‑grade configuration that demonstrates how Conditional Expressions, Dynamic Blocks, and Splat Expressions complement each other.
variable "environment" {
type = string
}
variable "ingress_rules" {
type = map(object({
port = number
cidr_blocks = list(string)
}))
}
# Conditional: Different instance counts and types per environment
resource "aws_instance" "app" {
count = var.environment == "production" ? 3 : 1
ami = var.ami_id
instance_type = var.environment == "production" ? "t3.large" : "t3.micro"
vpc_security_group_ids = [aws_security_group.app.id]
tags = {
Name = "${var.environment}-app-${count.index}"
Environment = var.environment
}
}
# Dynamic: Generate security rules from a variable
resource "aws_security_group" "app" {
name = "${var.environment}-app-sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidr_blocks
}
}
}
# Splat: Extract all instance IDs for output
output "instance_ids" {
description = "IDs of all app instances"
value = aws_instance.app[*].id
}
# Splat: Get all private IPs for load balancer configuration
output "private_ips" {
description = "Private IPs for load balancer config"
value = aws_instance.app[*].private_ip
}
- Conditional expressions give you environment‑aware configurations without file duplication.
- Dynamic blocks eliminate repetitive nested blocks, making security groups and policies data‑driven and maintainable.
- Splat expressions make data extraction effortless, removing the need for manual loops.
Happy Terraforming! 🚀