Loading
Terraform · Certification · IaC

Late Night Terraform: The Logic Gate

Coding with intelligence: leveraging expressions and built-in functions.

Late Night Terraform: The Logic Gate

🎙️ Opening Monologue

It’s 12:15 AM. The house is so quiet I can hear the hum of the refrigerator from two rooms away. My terminal is a sea of green text, but something feels off.

Up until now, my code has been obedient. It does exactly what I say, every single time, without question. But tonight, obedience isn’t enough.

I’m staring at patterns — repetition, variation, edge cases. Dozens of similar resources, each needing slight differences in names, rules, and thresholds. There’s no universe where copying and pasting this over and over ends well.

Late nights make this obvious: infrastructure doesn’t just need instructions, it needs reasoning. At some point, configuration has to stop repeating itself and start making decisions.

When patterns emerge, logic takes over.

Welcome to the Logic Gate.

🎯Episode Objective

This episode aligns with the Terraform Associate (004) exam objectives listed below.

  • Write dynamic configuration using expressions and functions

Terraform uses expressions to represent or compute values within your configuration, and functions to transform those values. Think of expressions as the “sentences” of your code and functions as the “verbs” that get things done.

The Textual Fabric: Manipulating Strings and Templates

In Terraform, strings are more than just static text; they are dynamic containers that allow you to inject logic, variables, and formatting directly into your infrastructure code. Whether you are naming an S3 bucket or generating a complex configuration file for a virtual machine, understanding how Terraform handles text is essential.

String Syntax Styles

Terraform provides two primary ways to define strings, depending on the length and complexity of the content.

  1. Quoted Strings

The most common form, used for short, single-line values. They are wrapped in double quotes (").

  • Best for: Resource names, IDs, and simple labels.
  • Example: name = "web-server-01"

2. Heredoc Strings

Inspired by Unix shell scripts, these allow for multi-line strings without needing messy escape characters for every newline.

  • Standard (<<): Preserves all indentation exactly as written.
  • Indented (<<-): Automatically “out-dents” the text to match the surrounding code’s indentation, keeping your .tf files clean.
  • Best for: Bash scripts, JSON policies, and multi-line configuration files.

Dynamic Content: Interpolation

Interpolation is the process of inserting a calculated value into a string using the ${ ... } syntax. This allows your strings to react to the state of your infrastructure.

variable "project" { default = "alpha" }
variable "env"     { default = "prod" }
resource "azurerm_resource_group" "example" {
  # Interpolating multiple variables into a single string
  name     = "rg-${var.project}-${var.env}-01"
  location = "East US"
}

When Terraform runs, it evaluates the expression inside the brackets and replaces the placeholder with the actual value.

Logic in Strings: Directives

Directives allow you to use conditional logic (if/else) and iteration (for loops) directly inside a string or template. This is incredibly powerful for generating configuration files that change based on how many resources you are deploying.

  • Conditional: Show text only if a certain variable is true.
  • For Loops: Generate a list of IP addresses or hostnames automatically.

1. The if Directive

The if directive allows you to toggle specific parts of a string based on a condition. This is perfect for optional configuration lines or dynamic greetings.

Syntax

"%{ if <CONDITION> }<TRUE_VAL>%{ else }<FALSE_VAL>%{ endif }"
  • The else block is optional. You can use just if and endif.

Example: Dynamic VM Tagging

You might want to mark a resource as “Temporary” only if it’s in a Dev environment.

"Description: This VM is %{ if var.is_prod }Production Grade%{ else }Temporary/Dev%{ endif }."

2. The for Directive

The for directive iterates over lists or maps. It is most commonly used in Heredocs to generate configuration files (like Nginx upstream blocks or host files).

Syntax

"%{ for <ELEMENT_VAR> in <COLLECTION> }<STRING_CONTENT>%{ endfor }"

Example: Generating an /etc/hosts file

Suppose you have a cluster of Azure VMs and you need to list all their private IPs in a single text block.

<<-EOT
%{ for vm in azurerm_linux_virtual_machine.example ~}
${vm.private_ip_address} ${vm.name}
%{ endfor ~}
EOT

3. Combining Directives and Whitespace Stripping

Because for loops and if statements in templates often require multiple lines for readability, they can accidentally introduce unwanted “ghost” newlines or spaces into your final output.

By adding ~ to the opening or closing tag, you tell Terraform to “eat” the whitespace in that direction.

# Input
<<EOT
%{~ if var.create_user ~}
User: Admin
%{~ endif ~}
EOT

Escape Sequences

In Terraform, escape sequences are essential for distinguishing between literal characters you want to display and the special syntax Terraform uses for variables and logic.

Why Escape Sequences Matter

When you are writing configurations that interact with the operating system or other languages (like JSON or Bash), you often run into “syntax collisions.” For example, if you want to write a JSON string inside a Terraform string, you must escape the internal double quotes so Terraform doesn’t think the string has ended.

Common Escaping Scenarios:

1. The “Literal Interpolation and Directive” Escape

If you are writing a script that uses its own ${} or %{} syntax (like a shell script variable) and you don’t want Terraform to try and fill it in, you use a double dollar or percentage sign.

  • Terraform sees: $${VAR_NAME} or %%{<expression>}
  • Resulting Output: ${VAR_NAME} and %{<expression>}
# Inside a resource block
# We want the OS to handle the variable, not Terraform
command = "echo $${USER_NAME} is logged in."

2. Unicode and Special Characters

Terraform allows you to inject precise characters using Unicode escapes. This is particularly useful for localized strings or special symbols in tags.

  • \uNNNN: Use this for 16-bit Unicode (e.g., \u00A9 for the © symbol).
  • \UNNNNNNNN: Use this for 32-bit Unicode (e.g., emojis or rare scripts).

3. Common escape sequences

  • \n (Newline): Breaks the string and starts a new line.
  • \r **(Carriage Return)**Moves the cursor to the beginning of the line (often used alongside \n for Windows-style line endings).
  • \t (Tab): Inserts a horizontal tab space.
  • " (Literal Quote): Includes a double-quote character inside a quoted string without ending the string.
  • \ (Literal Backslash): Includes a single literal backslash (escaping the escape character itself).

The Arithmetic of Cloud: Using Operators and Conditional Expressions

Terraform operators are the “engine” of your expressions. They allow you to perform math, compare values, and build logic to make your infrastructure dynamic.

Types of Operators

1. Arithmetic Operators

Used for mathematical calculations.

  • +, - (Addition, Subtraction)
  • *, / (Multiplication, Division)
  • % (Modulo/Remainder)
  • - (Unary negation, e.g., -5)

2. Comparison Operators

These return a boolean (true or false) and are often used in Conditional Expressions.

  • >, >= (Greater than, Greater than or equal to)
  • <, <= (Less than, Less than or equal to)

3. Logical Operators

Used to combine multiple boolean conditions.

  • && (AND): Returns true if both are true.
  • || (OR): Returns true if at least one is true.
  • ! (NOT): Inverts the boolean value.

4. Equality Operators

  • == (Equal)
  • != (Not equal)

The Equality “Gotcha” (Structural Types)

Comparing complex objects like lists or maps can be tricky because Terraform is strict about types, not just values.

The Tuple vs. List Problem

In Terraform, [] is technically a tuple, not a list. If your variable is defined as a list(string), the comparison var.list == [] will always be false, even if the list is empty.

The Solution: Instead of checking for equality against an empty structure, use the length() function:

# Recommended approach
count = length(var.subnet_ids) == 0 ? 1 : 0

Conditional Expressions

Conditional expressions are the primary way to implement logic in your Terraform files. Since Terraform is declarative, you don’t use traditional if/else code blocks; instead, you use this ternary operator to decide which value a configuration should take.

Syntax and Logic

The syntax is structured like a question: condition ? true_val : false_val

  • Condition: An expression that must resolve to a boolean (true or false).
  • True/False Values: The potential results.

The Type-Consistency Rule

As you noted, Terraform must know the “return type” of the entire expression during the planning phase. If true_val is a String and false_val is a Number, Terraform will try to find a common ground (usually converting the number to a string).

Pro-Tip: To avoid unexpected behavior, use explicit conversion functions like _tostring()_, _tonumber()_, or _tolist()_.

The Efficiency Engines: Transforming Collections with for and splat Expressions

for Expression

The for expression is one of Terraform’s most powerful tools for data transformation. It allows you to loop over a collection (list, set, tuple, or map) and generate a new collection, modifying the data as it passes through.

Syntax

The syntax changes slightly depending on whether you want the result to be a List (ordered) or a Map (key-value pairs).

To produce a List/Tuple:

Uses square brackets [].

[for <ITEM> in <COLLECTION> : <TRANSFORMATION>]

To produce a Map:

Uses curly braces {} and requires a key-value mapping (=>).

{for <KEY>, <VALUE> in <MAP> : <NEW_KEY> => <NEW_VALUE>}

Filtering with if

A for expression can also filter the input collection. If you add an if clause at the end, Terraform will only include elements that meet that condition.

Syntax: [for s in var.list : upper(s) if s != "skip_me"]

Key Considerations

  • Input Types: You can iterate over lists, sets, tuples, and maps.
  • Result Types: If the input is a list and the output is a list, the order is preserved. If the input is a map, the order is not guaranteed.
  • Unique Keys: When generating a Map, the expression for the key must produce a unique value for every iteration. If you produce duplicate keys, Terraform will throw an error (unless you use the grouping syntax ...).

Splat expressions

Splat expressions are the “shortcut” of the Terraform world. While a for expression is a surgical tool that can transform and filter data, the splat operator ([*]) is a high-speed extractor used to pull a single attribute out of a collection of objects.

The splat operator essentially says: “I don’t care about the individual objects; just give me a list of all their ‘X’ attributes.”

If you have a collection of virtual machines, each with a name and an ID, the splat operator flattens that structure.

  • Input: A list of objects.
  • Expression: azurerm_linux_virtual_machine.example[*].id
  • Result: A simple list of strings (the IDs).

Example:

resource "azurerm_network_interface" "example" {
  count = 3
  # ... configuration ...
}

output "nic_ids" {
  # Traditional 'for' way: [for nic in azurerm_network_interface.example : nic.id]
  # Concise 'splat' way:
  value = azurerm_network_interface.example[*].id
}

Limitation: No Map Splatting

It is a common mistake to try var.my_map[*].value. This will fail. For maps, you must stick to the for syntax: [for k, v in var.my_map : v.id]

Architectural Origami: Scaling Configuration with Dynamic Blocks

Dynamic blocks are the bridge between your data structures and the repeating configuration patterns required by cloud providers. While a for expression transforms data into a variable, a dynamic block transforms data directly into infrastructure components.

Anatomy of a Dynamic Block

Think of a dynamic block as a “factory” for nested configurations.

dynamic "label" {             # The name of the nested block (e.g., "storage_os_disk")
  for_each = var.collection   # The data to loop over
  iterator = item             # (Optional) Custom name for the loop variable
  
  content {                   # The actual attributes of the nested block
    name  = item.value.name
    setting = item.value.value
  }
}

The Iterator Object

The iterator (or the default block label if iterator is omitted) provides two handles:

  • key: The index (if a list) or the map key.
  • value: The actual data stored in that element.

Example: Network Security Rules

A classic use case in Azure is defining multiple rules for a Network Security Group (NSG). Instead of writing 10 security_rule blocks, you use one dynamic block.

variable "inbound_rules" {
  type = map(object({
    port     = number
    priority = number
  }))
  default = {
    "HTTP"  = { port = 80, priority = 100 }
    "HTTPS" = { port = 443, priority = 110 }
  }
}

resource "azurerm_network_security_group" "example" {
  name                = "production-nsg"
  location            = "East US"
  resource_group_name = "rg-prod"
  dynamic "security_rule" {
    for_each = var.inbound_rules
    content {
      name                       = security_rule.key # Uses "HTTP" or "HTTPS"
      priority                   = security_rule.value.priority
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_port_range          = "*"
      destination_port_range     = security_rule.value.port
      source_address_prefix      = "*"
      destination_address_prefix = "*"
    }
  }
}

Advanced Pattern: Filtering with for

As you noted, for_each accepts any collection. You can combine a for expression inside the for_each argument to filter which blocks get created.

resource "azurerm_linux_virtual_machine" "example" {
  # ... VM config ...
dynamic "storage_data_disk" {
    # Only create disks that are marked as 'managed'
    for_each = [for d in var.disks : d if d.type == "managed"]
    
    content {
      name              = storage_data_disk.value.name
      create_option     = "Empty"
      lun               = storage_data_disk.key
      disk_size_gb      = storage_data_disk.value.size
    }
  }
}

Constraints and Best Practices

As powerful as they are, dynamic blocks come with strict rules:

  • Meta-arguments are off-limits: You cannot use dynamic to generate lifecycle, provisioner, depends_on, or count blocks.
  • Readability is key: If you have exactly two disks, just write two storage_data_disk blocks. Dynamic blocks are meant for variable counts where the exact number isn’t known until the user provides input.
  • Maintenance: Over-nesting dynamic blocks (a dynamic block inside a dynamic block) can make debugging extremely difficult.

Version Constraints

Version constraints act as the “safety belt” for your infrastructure code. They prevent a situation where a new, breaking release of a provider or a module automatically downloads and breaks your production environment during a terraform init.

Where to Apply Constraints

In Azure environments, you primarily apply these in the terraform {} block to lock down the AzureRM provider and the Terraform binary itself.

terraform {
  required_version = ">= 1.5.0" # Ensures features like 'import' blocks are available
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0" # Allows 3.1, 3.99, but NOT 4.0.0
    }
  }
}

Constraints

  • = (or no operator): Restricts the component to one exact version number only.
  • !=:** Excludes a specific version number while allowing others within a specified range.
  • > , >= , < , <=:** Permits any version that is numerically greater than, less than, or equal to the specified version.
  • ~>:** Allows only the right-most digit of the version string to increment, preventing major breaking changes.
  • ,:** Acts as a logical “AND” to join multiple conditions into a single version constraint.

Understanding the Pessimistic Operator (~>)

The ~> operator is the most critical for day-to-day use. It follows Semantic Versioning (SemVer) rules: Major.Minor.Patch.

  • ~> 3.10.0: “I trust bug fixes.” It locks Major and Minor, allows Patch to change.
  • ~> 3.10: “I trust new features, but no breaking changes.” It locks Major, allows Minor and Patch to change.

Implementation Strategy

  • For Root Modules (Your Project): Use ~> for providers. This keeps you secure while allowing minor upgrades.
  • For Reusable Modules (Your Library): Use >=. This ensures your module is compatible with a wide range of versions without forcing the end-user to downgrade.
  • Lock Files (.terraform.lock.hcl): Remember that even if you use a range like ~> 3.0, Terraform creates a lock file. This ensures that everyone on your team uses the exact same version until you explicitly run terraform init -upgrade.

The Built-in Toolkit: Leveraging Terraform Functions

Terraform functions are the “logic engine” that allow you to manipulate data without needing a traditional programming language. Because Terraform is declarative, these functions are designed to be pure (consistent results) or state-aware (handling time and files).

Built-in Function Lifecycle

Most functions (like max(), upper(), or split()) run instantly. However, as you noted, a few have special timing:

Static Functions (Validation Phase)

  • file() and templatefile(): These are executed during the initial scan.
  • The Constraint: You cannot use these to read a file created by a resource in the same plan (e.g., a key file generated by an Azure SSH resource).

Dynamic Functions (Apply Phase)

  • timestamp() and uuid(): To prevent “drift” between the plan and apply stages, Terraform treats these as (known after apply).
  • Azure Use Case: Using timestamp() in a tag to record exactly when a VM was created.

Provider-Defined Functions

Introduced in more recent versions of Terraform, these allow providers (like Azure, AWS, or Kubernetes) to offer specialized logic that doesn’t exist in the core language.

Syntax: provider::<PROVIDER_NAME>::<FUNCTION_NAME>(<ARGS>)

Example: If the Azure provider offered a specific CIDR math function not in core:

# Hypothetical example
address_space = provider::azurerm::parse_network_data(var.raw_input)

Function Categories List

Logic & Error Handling Functions (The “Resiliency” Tools):

  • lookup(map, key, default):** Retrieves a value from a map by key; returns a default if the key is missing.
  • try(exprs...):** Evaluates several expressions and returns the first one that doesn’t error.
  • can(expr):** Returns true if the expression evaluates without error, otherwise false.
  • coalesce(vals...):** Returns the first non-null, non-empty string from a list of arguments.
  • alltrue(list) / anytrue(list):** Returns true if all or any of the booleans in a list are true.

Collection Functions (Managing Structures)

  • element(list, index):** Safely retrieves an item from a list at a specific index (wraps around if index exceeds length).
  • index(list, value):** Finds the integer index of a specific element in a list.
  • keys(map) / values(map):** Extracts only the keys or only the values from a map into a list.
  • merge(maps...):** Combines multiple maps into one (later maps overwrite earlier ones).
  • flatten(list):** Collapses nested lists into a single flat list.
  • length(coll):** Returns the number of items in a list, map, or string.

String Functions (Formatting & Cleanup)

  • upper() / lower():** Changes text casing.
  • replace(string, search, replace):** Swaps specific text or patterns within a string.
  • trimprefix() / trimsuffix():** Removes specific characters from the start or end of a string.
  • join(separator, list):** Converts a list into a single string separated by a character (e.g., a comma).
  • split(separator, string):** Breaks a string into a list based on a separator.

IP Network Functions (Infrastructure Math)

  • cidrsubnet():** Carves a larger network prefix into smaller subnets.
  • cidrhost():** Calculates a specific host IP address within a network range.

Encoding & Filesystem Functions (Data & Templates)

  • jsonencode() / jsondecode():** Converts values to and from JSON format.
  • base64encode() / base64decode():** Transforms data into Base64 (critical for Azure custom_data).
  • templatefile(path, vars):** Reads a file and replaces placeholders with Terraform variables.
  • file(path):** Reads the raw contents of a file as a string.

Numeric & Hash Functions (Calculations & Security)

  • min() / max():** Finds the smallest or largest value in a set.
  • uuid():** Generates a unique, random string (decided at apply-time).
  • sha256():** Generates a secure cryptographic hash for verifying file integrity.

The Sandbox: Experimenting with Logic Using terraform console

The terraform console command is an interactive command-line interface (CLI) tool used to test and evaluate expressions, functions, and variables in the context of your current configuration. It is the “sandbox” of the Terraform world.

Why Use the Console?

  • Debug Logic: Test complex for expressions or if/else logic before committing them to a .tf file.
  • Validate Functions: See exactly how cidrsubnet() or jsonencode() will transform your data.
  • Inspect State: If you have an active state file, you can query the actual values of your deployed resources (e.g., viewing an Azure VM’s private IP).
  • Type Checking: Confirm if an expression returns a list, a tuple, or a map.

Basic Usage

To enter the console, run the following command in the same directory as your Terraform files:

terraform console

Once inside, you will see a > prompt. You can type any valid Terraform expression and press Enter to see the result. To exit, type exit or press Ctrl+C.

Examples in the Console

Testing Math and Logic:

> 1 + 5
6
> var.env == "prod" ? "Standard_D4s_v3" : "Standard_B2s"
"Standard_B2s"

Testing String Functions:

> upper("azure-resource")
"AZURE-RESOURCE"
> join("-", ["rg", "prod", "eastus"])
"rg-prod-eastus"

Testing Collection Transformations:

> [for s in ["web", "db"] : upper(s)]
[  "WEB",
  "DB",
]

Interaction with Configuration and State

The console has access to your variables.tf, locals.tf, and your current State.

  • Variables: If you have a variable named prefix, typing var.prefix will show its value.
  • Resources: If you have already run terraform apply, you can inspect live attributes: azurerm_public_ip.example.ip_address
  • Calculations: You can test how a function interacts with your actual variables: cidrhost(var.vnet_range, 10)

Important Constraints

  • Read-Only: The console cannot modify your infrastructure or change your state file. It is a “read-only” environment for evaluation.
  • Locking: If you are using a remote backend (like an Azure Storage Account) with state locking, running the console will lock the state. Other team members won’t be able to run plan or apply until you exit the console.
  • No Multi-line: The console generally expects single-line expressions. For multi-line logic, it is better to use terraform plan and locals.

💡 Pro-Tip

You can pipe expressions into the console directly from the terminal without entering the interactive mode. This is great for automation or quick checks:

echo “upper(\”hello\”)” | terraform console

🌙 Late-Night Reflection

The goal of automation isn’t just to do things faster, but to do them smarter. However, adding logic adds cognitive load, and every conditional is a new path where something can go wrong. Elegant code isn’t the one that uses every trick in the book; it’s the one that handles complex decisions while remaining readable at 3:00 AM.

✅ Key Takeaways

  • Logic Style: Terraform is declarative. You don’t use if/then blocks like Python; you use conditional expressions (? :) and directives (%{if}) to achieve logic.
  • Use <<- (Indented Heredoc) to keep your Azure startup scripts readable.
  • The ~ (tilde) in template directives is essential for stripping “ghost” newlines in generated files.
  • try() and can() are your best tools for handling optional attributes in complex Azure objects.
  • Avoid direct equality checks on lists (e.g., var.list == []); always prefer length(var.list) == 0.
  • The Power of for:** The for expression is more versatile than the Splat operator ([*]) because it can filter (using if) and transform (using upper() or math).

📚 Further Reading

🎬 What’s Next

Smart systems are powerful — until a mistake slips through. At 2:00 AM, intelligence isn’t enough.

We’ll build guardrails that protect infrastructure from tired decisions and irreversible damage.

This post is part of a series
Late Night Terraform
Discussion

Comments