SaveMyCert
Log in
5 of 5 free questions left today·for unlimited practice
Terraform configuration

Terraform Expressions, Functions, Dependencies, and Sensitive Data

13 min readTerraform 004 · Terraform configurationUpdated

Dynamic Terraform configuration comes from three tools: expressions (references, conditionals, for expressions, splat, and string templates), built-in functions like lookup and cidrsubnet, and the count and for_each meta-arguments that stamp out multiple resource instances from one block. The Associate (004) exam tests all three, plus the two ways to declare dependencies — implicit references versus an explicit depends_on — and the custom conditions (variable validation, precondition, postcondition) that catch bad assumptions at plan time. It also tests sensitive-data hygiene: the sensitive flag redacts CLI output but state stores everything in plaintext, secrets must never be hardcoded or committed, and HashiCorp Vault is the recommended secrets manager, consumed through the vault provider. This lesson works through each area with short HCL you can verify against Terraform 1.12, including a count-versus-for_each comparison and the one fact candidates forget: you cannot write your own functions in Terraform configuration.

What you’ll learn
  • Write conditional expressions, for expressions, splat expressions, and string templates
  • Apply built-in functions such as lookup, element, length, join, cidrsubnet, file, and templatefile
  • Choose between count and for_each and generate nested blocks with dynamic blocks
  • Distinguish implicit dependencies from explicit depends_on and know when each applies
  • Validate configuration with variable validation blocks and lifecycle preconditions and postconditions
  • Handle sensitive data correctly, including Vault-based secrets management and the plaintext-state caveat

Expressions: references, conditionals, and string templates

Expressions are how values are computed anywhere an argument accepts one. The simplest expressions are literals and references — var.environment, aws_subnet.app.id, data.aws_ami.ubuntu.id, local.common_tags, module.network.vpc_id, each.value, count.index. On top of those, Terraform supports arithmetic, comparison, and logical operators, plus a ternary conditional expression:

instance_type = var.environment == "prod" ? "m5.large" : "t3.micro"

The syntax is exactly condition ? true_val : false_val, and both result values must be convertible to a common type. Conditionals are the standard way to vary sizing, counts, or feature flags per environment without duplicating blocks.

String templates interpolate expressions into strings with ${...} and support directives with %{...}:

name    = "web-${var.environment}-${count.index}"
message = "%{ if var.environment == "prod" }Handle with care%{ else }Sandbox%{ endif }"

Interpolation-only strings like "${var.name}" are legal but redundant — write var.name directly. The exam expects you to read all of these forms fluently and predict what they evaluate to.

for expressions and splat expressions transform collections

A for expression builds a new collection by transforming another. Square brackets produce a list, curly braces with => produce a map, and an optional if clause filters:

locals {
  upper_zones  = [for z in var.zones : upper(z)]
  public_ids   = [for s in aws_subnet.all : s.id if s.map_public_ip_on_launch]
  name_by_env  = {for e in var.environments : e => "app-${e}"}
}

When iterating a map, bind both sides: {for k, v in var.tags : k => lower(v)}.

A splat expression is shorthand for the most common for expression — extracting one attribute from every element of a list:

output "instance_ids" {
  value = aws_instance.web[*].id
}

aws_instance.web[*].id is equivalent to [for o in aws_instance.web : o.id]. Splat works on lists and sets — including resources created with count, which form a list. Resources created with for_each form a map, so splat does not apply directly; use values(aws_instance.web)[*].id or a for expression instead. That count-list versus for_each-map distinction is a favorite exam discriminator.

Built-in functions you must recognize

Terraform ships a large library of built-in functions, and — a fact tested directly — you cannot define your own functions in Terraform configuration. (Providers may publish extra functions with a provider:: prefix, but the language itself has no user-defined functions.) The exam expects you to recognize what the common built-ins return:

  • lookup(map, key, default) — a map value, or the default when the key is absent: lookup(var.amis, var.region, "ami-default")
  • element(list, index) — the element at that index, wrapping around when the index exceeds the list length: element(["a", "b", "c"], 4) returns "b"
  • length(value) — the number of elements in a list, map, or set, or characters in a string
  • join(separator, list) — one string from a list: join(",", var.zones)
  • cidrsubnet(prefix, newbits, netnum) — a subnet CIDR carved from a larger block: cidrsubnet("10.0.0.0/16", 8, 2) returns "10.0.2.0/24"
  • file(path) — the raw contents of a file as a string
  • templatefile(path, vars) — a file rendered as a string template with the given variables, the standard way to build cloud-init user data
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  user_data     = templatefile("${path.module}/init.tftpl", {
    app_port = var.app_port
  })
}

You can experiment with any function interactively in terraform console, which is also how the tutorials suggest you study them.

count and for_each create multiple resource instances

The count and for_each meta-arguments turn one resource block into several instances — and a resource can use one or the other, never both.

resource "aws_instance" "worker" {
  count         = var.environment == "prod" ? 3 : 1
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  tags          = { Name = "worker-${count.index}" }
}

resource "aws_instance" "service" {
  for_each      = {
    api   = "t3.small"
    batch = "t3.large"
  }
  ami           = data.aws_ami.ubuntu.id
  instance_type = each.value
  tags          = { Name = each.key }
}
countfor_each
InputA whole numberA map, or a set of strings
Per-instance referencecount.index (0, 1, 2...)each.key and each.value
Instance addressaws_instance.worker[0]aws_instance.service["api"]
Result typeList of instancesMap of instances
Removing a middle itemReindexes later items, forcing destroy/recreateOnly the removed key's instance is destroyed
Best forIdentical copies, on/off toggles (count = 0 or 1)Distinct named instances that change over time

The reindexing row is the practical reason to prefer for_each for collections of distinct objects: deleting element 0 of a three-instance count shifts instances 1 and 2 to new addresses, and Terraform plans to destroy and recreate them. With for_each, keys are stable. Also remember that for_each keys must be known at plan time — you cannot key instances on an attribute that is only known after apply.

dynamic blocks generate repeated nested blocks

Meta-arguments repeat whole resources; a dynamic block repeats a nested block inside one resource, driven by a collection. The classic case is security group rules:

variable "ingress_rules" {
  type = list(object({
    port  = number
    cidrs = list(string)
  }))
}

resource "aws_security_group" "web" {
  name = "web-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.cidrs
    }
  }
}

The label on the dynamic block names the nested block to generate, its for_each supplies the collection, and inside content the iterator carries the block's own name — ingress.value here — unless you override it with an iterator argument.

Use dynamic blocks sparingly: they make configuration harder to read, and HashiCorp's guidance is to prefer literal nested blocks unless the repetition genuinely needs to be data-driven.

Implicit versus explicit dependencies

Terraform orders operations from a dependency graph, and there are exactly two ways a dependency gets into that graph. An implicit dependency is created automatically whenever one resource's expression references another — subnet_id = aws_subnet.app.id means the subnet is created first. This is the preferred mechanism: it is self-maintaining and impossible to forget.

An explicit dependency uses the depends_on meta-argument for relationships Terraform cannot see in any expression — a hidden, behavioral dependency:

resource "aws_iam_role_policy" "app" {
  role   = aws_iam_role.app.name
  policy = data.aws_iam_policy_document.s3.json
}

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  iam_instance_profile = aws_iam_instance_profile.app.name

  depends_on = [aws_iam_role_policy.app]
}

The instance references the instance profile, but nothing in its arguments mentions the role policy — yet the application on the instance will fail at boot if the policy is not attached first. depends_on accepts a list of resource or module references and forces the ordering.

For the exam: implicit dependencies come from references and are preferred; depends_on is a last resort for dependencies invisible to Terraform, and overusing it creates ordering constraints that slow plans and hide the real relationships.

Custom conditions: validation, precondition, postcondition

Custom conditions let a configuration check its own assumptions and fail with a clear message instead of a confusing provider error. There are three placements to know.

Variable validation guards inputs at the earliest possible moment:

variable "instance_type" {
  type = string

  validation {
    condition     = can(regex("^t3\\.", var.instance_type))
    error_message = "Only t3-family instance types are allowed."
  }
}

Preconditions and postconditions live inside a resource, data source, or output lifecycle block. A precondition checks an assumption before Terraform acts; a postcondition checks a guarantee afterwards and may reference the resource's own result via self:

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type

  lifecycle {
    precondition {
      condition     = data.aws_ami.ubuntu.architecture == "x86_64"
      error_message = "The selected AMI must be x86_64."
    }
    postcondition {
      condition     = self.public_dns != ""
      error_message = "The instance must have a public DNS name."
    }
  }
}

Every condition pairs a boolean condition with an error_message. Failed conditions halt the run with your message. Terraform also has standalone check blocks whose failures are only warnings — useful for ongoing health assertions — but the enforcing tools are validation, precondition, and postcondition.

Managing sensitive data and secrets with Vault

The ground truth of Terraform secrets handling is this: the state file stores all values in plaintext, including variables and outputs marked sensitive. The sensitive = true flag redacts values from plan and apply output so they do not leak into logs, but it encrypts nothing. Consequently the real controls are operational: never hardcode credentials in .tf files, never commit .tfvars files containing secrets (gitignore them), pass secrets via TF_VAR_ environment variables or a secrets manager, and store state in an access-controlled, encrypted remote backend.

HashiCorp's recommended secrets manager is Vault, consumed through the vault provider. Terraform authenticates to Vault and reads secrets with data sources, so credentials live centrally with audit logging and rotation instead of scattered across configurations:

provider "vault" {}

data "vault_kv_secret_v2" "db" {
  mount = "secret"
  name  = "database"
}

resource "aws_db_instance" "app" {
  engine   = "postgres"
  username = "app"
  password = data.vault_kv_secret_v2.db.data["password"]
}

Two caveats the exam rewards you for knowing. First, any secret Terraform reads — even from Vault — is still written to the plan and state in plaintext, so Vault reduces sprawl and enables short-lived dynamic credentials, but state protection remains essential. Second, modern Terraform (1.10+) adds ephemeral values, and 1.11+ adds provider write-only arguments, which let certain secrets be used during a run without ever being persisted to plan or state — the direction of travel for secret-safe configuration in Terraform 1.12.

Tip. Expect to evaluate expressions on sight: predict the result of a conditional, a for expression, a splat, or a function call like element with an out-of-range index or cidrsubnet with given arguments. Count-versus-for_each scenarios are common, especially the reindexing behavior when a count list changes and the map-versus-list result types. You will also see questions on when depends_on is required versus an implicit reference, where validation, precondition, and postcondition each belong, and sensitive-data facts — above all that state stores everything, including Vault-sourced secrets, in plaintext.

Key takeaways
  • The conditional expression is condition ? true_val : false_val; for expressions build lists with brackets and maps with braces, with an optional if filter.
  • Splat (resource[*].attribute) works on count-created lists; for_each-created resources form a map, so use values() or a for expression.
  • You cannot write custom functions in Terraform configuration — only built-ins (and provider-published functions) exist; test them in terraform console.
  • count takes a number and yields indexed instances; for_each takes a map or set of strings and yields stable, key-addressed instances — never use both on one resource.
  • Removing a middle element under count reindexes and recreates later instances; for_each only touches the removed key.
  • Implicit dependencies from attribute references are preferred; depends_on is the explicit last resort for dependencies Terraform cannot infer.
  • Variable validation, precondition, and postcondition all pair a condition with an error_message and fail the run; postconditions can inspect self.
  • sensitive = true redacts CLI output only — state and plan files hold secrets in plaintext, so use Vault for secrets management and lock down state.

Frequently asked questions

When should I use for_each instead of count?

Use for_each whenever the instances are distinct named things that may be added or removed over time, because instances are addressed by stable keys and removing one never disturbs the others. Use count for genuinely identical replicas or as an on/off toggle with count = 0 or 1. The failure mode that decides it: deleting a middle element under count shifts every later index, and Terraform plans to destroy and recreate those instances.

Can I write my own functions in Terraform?

No. The Terraform language only supports its built-in functions, plus functions published by providers under a provider:: prefix. There is no mechanism to define custom functions inside your configuration. This is a directly tested fact — if a question offers user-defined functions as an option, it is wrong. Use terraform console to explore what the built-in functions return.

When do I actually need depends_on?

Only when a resource depends on another's behavior but no expression in its arguments references that resource, so Terraform cannot infer the ordering. The classic example is an instance whose software needs an IAM role policy attached before boot. If you can express the dependency through an attribute reference instead, do that — implicit dependencies are self-maintaining, and unnecessary depends_on entries create hidden ordering constraints.

What is the difference between variable validation and a precondition?

Variable validation lives in the variable block and checks input values as early as possible, before any resource logic runs. Preconditions live in a resource, data source, or output lifecycle block and can check anything visible at that point — including data source results, like verifying a looked-up AMI's architecture. Postconditions run after the resource is planned or applied and can reference the result through self. All three fail the run with your error_message.

If I read secrets from Vault, are they kept out of the Terraform state file?

No. Any value Terraform reads through a data source — including Vault secrets — is persisted in the plan and state in plaintext. Vault still helps: secrets are centrally stored, audited, rotated, and can be short-lived dynamic credentials so a leaked state file ages out quickly. But you must still protect state with an encrypted, access-controlled remote backend. Ephemeral values (Terraform 1.10+) and write-only arguments (1.11+) are the newer mechanisms that avoid persisting certain secrets at all.

What does cidrsubnet actually return?

It carves a smaller subnet out of a larger CIDR prefix. cidrsubnet(prefix, newbits, netnum) extends the prefix length by newbits and selects subnet number netnum. For example, cidrsubnet("10.0.0.0/16", 8, 2) extends /16 by 8 bits to /24 and picks the third subnet, returning "10.0.2.0/24". It is the standard way to derive per-zone subnets from one VPC block.

Test yourself on this topic
Practice questions with full explanations.
Practice now

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.