Expert Patterns
The deep end of Terraform - optional object attributes, check blocks, passing providers to modules, depends_on and the graph, terraform_data and provisioners, ephemeral values, and debugging
Expert Patterns
The features here aren't part of the daily 80%. You reach for them when you're building shared modules, enforcing invariants across a fleet, handling secrets that must never touch state, or debugging why a plan does something surprising. Knowing they exist is what separates someone who uses Terraform from someone who can be handed the gnarly problem.
Rich Type Constraints
Beyond string and list, Terraform has a full type system. The expert tool is object(...) with optional() attributes that carry defaults — it lets one variable model a complex, partially-specified input:
variable "clusters" {
type = map(object({
instance_type = optional(string, "t3.medium")
min_size = optional(number, 1)
max_size = optional(number, 3)
spot = optional(bool, false)
labels = optional(map(string), {})
}))
}A caller can now write { web = { max_size = 10 } } and every other field fills in from the declared defaults. This replaces the old pattern of a dozen flat variables plus coalesce() everywhere.
optional(type, default) is the cleanest way to design a module's interface: required attributes have no optional(), tunable ones get a sensible default. The caller's config stays minimal and the module's contract is self-documenting.
check Blocks: Continuous Validation
precondition / postcondition (covered in Advanced Patterns) are tied to a specific resource and block the apply when they fail. A check block (Terraform 1.5+) is independent and non-blocking — it's an assertion about your live system that surfaces as a warning, ideal for health and contract checks:
check "app_is_healthy" {
data "http" "health" {
url = "https://${aws_lb.main.dns_name}/healthz"
}
assert {
condition = data.http.health.status_code == 200
error_message = "App health endpoint returned ${data.http.health.status_code}"
}
}Because check blocks don't fail the apply, they're safe to add everywhere: post-deploy smoke tests, "this certificate expires in > 30 days", "the bucket policy is still private". Run terraform plan on a schedule and you get continuous validation of reality, not just config.
precondition / postcondition | check block | |
|---|---|---|
| Scope | One resource/data block | Standalone, whole config |
| On failure | Blocks the apply | Warns, apply continues |
| Use for | Hard invariants ("prod DB ≥ 100 GB") | Health checks, soft contracts, drift signals |
Passing Providers to Modules
A reusable module shouldn't declare its own provider {} block (it breaks multi-region use). But some modules genuinely need more than one provider instance — a module that replicates a bucket from us-east-1 to eu-west-1, for example. Declare the requirement with configuration_aliases:
# modules/replicated-bucket/versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
configuration_aliases = [aws.primary, aws.replica]
}
}
}
# modules/replicated-bucket/main.tf
resource "aws_s3_bucket" "primary" {
provider = aws.primary
bucket = "${var.name}-primary"
}
resource "aws_s3_bucket" "replica" {
provider = aws.replica
bucket = "${var.name}-replica"
}The caller wires concrete provider instances into the module's aliases:
module "logs" {
source = "./modules/replicated-bucket"
name = "myapp-logs"
providers = {
aws.primary = aws.us_east
aws.replica = aws.eu_west
}
}depends_on and the Dependency Graph
Terraform builds a dependency graph (a DAG) from references: if resource A reads B.id, A depends on B, and Terraform parallelizes everything else. Most of the time references are enough. depends_on is the escape hatch for hidden dependencies the graph can't see:
# The app can't start until the IAM policy is attached, but it never
# references the attachment — so declare the ordering explicitly.
resource "aws_instance" "app" {
# ...
depends_on = [aws_iam_role_policy_attachment.app]
}It works on modules too (depends_on = [module.network]). Use it sparingly — every depends_on you add removes parallelism and is usually a sign a reference would be cleaner.
To see the graph, and tune how much runs at once:
terraform graph | dot -Tsvg > graph.svg # visualize the DAG
terraform apply -parallelism=20 # default is 10terraform_data and Provisioners
Sometimes you must run a script as part of apply — bootstrap a node, trigger an external system. The modern host for this is terraform_data (it replaces the old null_resource and needs no extra provider):
resource "terraform_data" "bootstrap" {
# Re-run whenever the instance is replaced
triggers_replace = [aws_instance.app.id]
provisioner "local-exec" {
command = "./configure.sh ${aws_instance.app.public_ip}"
}
}terraform_data is also handy as a pure value holder — input/output to stash a computed value, or as a sequencing anchor with depends_on.
Provisioners are a last resort. They run only at create/destroy time, don't appear in the plan diff, and Terraform can't reconcile their effects. Reach for cloud-native init first: user_data, cloud-init, a golden AMI, or a config-management tool. Use local-exec/remote-exec only when there's truly no declarative path. A provisioner "x" { when = destroy } block runs cleanup on teardown — handy, but equally fragile.
Provider-Defined Functions
Terraform 1.8+ lets providers ship their own functions, called through the provider:: namespace. They fill gaps the built-in functions can't:
locals {
# A function contributed by the AWS provider
arn = provider::aws::arn_build("aws", "s3", "", "", "my-bucket")
parts = provider::aws::arn_parse(local.arn)
}When you hit a transformation the built-ins (jsonencode, cidrsubnet, templatefile…) can't express, check whether your provider offers a function before writing a hacky workaround.
Ephemeral Values: Secrets That Never Touch State
Terraform's oldest sharp edge is that everything ends up in state — including secrets pulled from a vault. Recent Terraform (1.10+) adds ephemeral constructs that exist only in memory during a run and are never written to state or plan files.
# An ephemeral resource — fetched fresh each run, never persisted
ephemeral "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/db/password"
}The companion feature is write-only arguments (provider attributes with a _wo suffix) that accept a value, send it to the API, and discard it instead of storing it:
resource "aws_db_instance" "main" {
# ...
password_wo = ephemeral.aws_secretsmanager_secret_version.db.secret_string
password_wo_version = 1 # bump to push a rotated password
}Variables and outputs can also be marked ephemeral = true to carry secrets through a module without persisting them. This is the proper fix for the "passwords sit in plaintext in tfstate" problem that sensitive = true only ever masked in output.
Debugging and Performance
When a plan does something inexplicable, turn on logging:
export TF_LOG=DEBUG # TRACE, DEBUG, INFO, WARN, ERROR
export TF_LOG_PATH=tf.log # tee logs to a file instead of stderr
terraform planTF_LOG=TRACE shows every provider API call — invaluable for "why is it recreating this?" mysteries.
For large configurations:
| Lever | Effect |
|---|---|
terraform plan -refresh=false | Skip the refresh phase; much faster, at the cost of stale drift detection |
terraform apply -target=... | Scope to one resource — a debugging tool, not a workflow (see Best Practices) |
-parallelism=N | Raise/lower concurrent operations (default 10) |
TF_PLUGIN_CACHE_DIR | Shared provider cache across configs — stop re-downloading providers on every init |
| Split the state | The real fix for a slow plan: a 500-resource state should be several smaller ones (see Refactoring) |
What's Next
You've seen the deep end of the language and runtime. The remaining pages are about operating Terraform at scale: orchestration with Terragrunt, validation in Testing, and the full production playbook in Best Practices.