Advanced HCL: Dynamic Blocks, Complex Expressions, and Custom Conditions
Scenario
You manage security groups for 12 different microservices. Each service has a different set of ingress rules defined in a YAML-like variable structure. Writing 12 separate aws_security_group resources with 6 ingress blocks each would be 240 lines of repetitive HCL. You need to use advanced Terraform HCL patterns — dynamic blocks, complex expressions, and lifecycle conditions — to keep the configuration concise and safe.
- Define a variable that is a map(object) where each object has a service name, a list of ingress rules (protocol, port, CIDR), and a boolean allow_all_egress.
- Use for_each to create one security group per service. Within each resource, use a dynamic "ingress" block to generate all ingress rules from the list.
- Use a dynamic "egress" block that conditionally adds an allow-all rule only when allow_all_egress == true.
- Add a lifecycle { precondition } that validates no service exposes port 22 to 0.0.0.0/0. Add a postcondition that verifies the SG was actually created in the correct VPC.
- Use a complex output with for expressions to produce a map of service name → list of ingress ports.
Additional Context
Dynamic blocks are for generating repeated nested blocks (like ingress, rule, setting) from a list or map. They use an iterator variable (default: the block label name) to reference the current item. Without dynamic blocks, you'd need a separate resource for each combination.
precondition vs variable validation: Variable validation runs at plan time for input values. lifecycle { precondition } runs during plan after the full configuration is evaluated — it can reference other resources, data sources, and computed values. postcondition runs during apply after the resource is created, verifying the actual result from AWS.
variables.tf
variable "vpc_id" { type = string }
variable "services" {
description = "Map of microservices with their security group rules."
type = map(object({
description = string
allow_all_egress = bool
ingress_rules = list(object({
port = number
protocol = string
cidr_blocks = list(string)
}))
}))
default = {
api = {
description = "Public-facing API"
allow_all_egress = true
ingress_rules = [
{ port = 443; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }
{ port = 80; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }
]
}
worker = {
description = "Background job worker"
allow_all_egress = true
ingress_rules = [
{ port = 8080; protocol = "tcp"; cidr_blocks = ["10.0.0.0/8"] }
]
}
db_proxy = {
description = "Database proxy — no outbound internet"
allow_all_egress = false # restricted egress
ingress_rules = [
{ port = 5432; protocol = "tcp"; cidr_blocks = ["10.0.0.0/8"] }
]
}
}
}main.tf
provider "aws" { region = "us-east-1" }
data "aws_vpc" "selected" { id = var.vpc_id }
resource "aws_security_group" "services" {
for_each = var.services
name = "sg-${each.key}"
description = each.value.description
vpc_id = var.vpc_id
# Dynamic ingress — generates one ingress block per rule in the list.
dynamic "ingress" {
for_each = each.value.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
# Dynamic egress — only adds the allow-all rule when explicitly enabled.
dynamic "egress" {
for_each = each.value.allow_all_egress ? [1] : []
content {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
tags = { Name = "sg-${each.key}", ManagedBy = "terraform" }
lifecycle {
# precondition: evaluated at plan time — catch SSH-to-world before apply.
precondition {
condition = !anytrue([
for rule in each.value.ingress_rules :
rule.port == 22 && contains(rule.cidr_blocks, "0.0.0.0/0")
])
error_message = "Service '${each.key}': Exposing port 22 to 0.0.0.0/0 is not permitted."
}
# postcondition: evaluated after apply — verify the SG is in the right VPC.
postcondition {
condition = self.vpc_id == var.vpc_id
error_message = "Security group was created in VPC ${self.vpc_id} but expected ${var.vpc_id}."
}
}
}outputs.tf
# Map of service → list of ingress ports (complex for expression).
output "service_ports" {
value = {
for name, sg in aws_security_group.services :
name => [
for rule in var.services[name].ingress_rules : rule.port
]
}
}
# Map of service → SG ID.
output "security_group_ids" {
value = { for name, sg in aws_security_group.services : name => sg.id }
}
# Services with restricted egress — filter using for + if.
output "restricted_egress_services" {
value = [
for name, svc in var.services : name if !svc.allow_all_egress
]
}Workflow Commands
terraform init && terraform apply -var="vpc_id=vpc-xxxx"
# Test precondition by adding a bad rule (should fail at plan, not apply)
# Temporarily add to variables:
# bad_service = {
# description = "test"; allow_all_egress = true
# ingress_rules = [{ port = 22; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }]
# }
terraform plan -var="vpc_id=vpc-xxxx" # → Error: port 22 to 0.0.0.0/0 is not permitted
# View complex outputs
terraform output -json service_ports
# { "api": [443, 80], "db_proxy": [5432], "worker": [8080] }
terraform output -json restricted_egress_services
# ["db_proxy"]
terraform output -json security_group_ids
terraform destroy -var="vpc_id=vpc-xxxx"Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
restricted_egress_services = ["db_proxy"]
security_group_ids = {
"api" = "sg-0aaa..."
"db_proxy" = "sg-0bbb..."
"worker" = "sg-0ccc..."
}
service_ports = {
"api" = [443, 80]
"db_proxy" = [5432]
"worker" = [8080]
}
