SaveMyCert
Log in
5 of 5 free questions left today·for 10 a day
Scalable Terraform configuration design

Terraform Professional (002): Turning Requirements into Predictable, Secure Configuration

22 min readTerraform Pro 002 · Scalable Terraform configuration designUpdated

Predictable, secure Terraform configuration is code whose inputs, conditions and dependencies behave the same way on every run and whose secrets are exposed only where the requirement allows. For the Terraform Infrastructure Engineer Professional exam that means turning a business requirement — allowed values, mandatory fields, environment differences, "never store this password" — into the exact Terraform construct that enforces it with the right timing, failure mode and persistence. This lesson covers typed inputs, locals, explicit environment inputs, stable for_each identity, validation and custom conditions, check blocks and health assessments, sensitive versus ephemeral values and write-only arguments, the dependency lock file, required_version and destruction guards.

What you’ll learn
  • Map a written business requirement to the Terraform mechanism whose timing, failure mode and storage behaviour match it
  • Design module inputs with object types, optional(type, default), required variables and nullable = false, knowing that variable defaults never merge with caller values
  • Derive naming and tagging conventions in locals and express environment differences as explicit module inputs
  • Choose stable, plan-time-known for_each keys and keep per-run values such as timestamp() out of configuration that must stay fixed
  • Choose between variable validation, preconditions, postconditions and check blocks, and explain what HCP Terraform health assessments add
  • Explain what sensitive protects and what it does not, and keep secrets out of state with ephemeral values and write-only arguments
  • Make builds reproducible with a committed multi-platform lock file and required_version, and guard critical resources with prevent_destroy plus provider deletion protection

From business requirement to Terraform mechanism

Translating a business requirement into infrastructure code means choosing the Terraform construct whose behaviour matches the requirement exactly — what it accepts, when it is evaluated, and what happens when it fails. Most requirements arrive as plain sentences ("only these three environments", "never print the key", "the database must survive a bad commit"), and each one maps to a specific mechanism. Picking the wrong one usually still "works" in the happy path but misses the timing, the failure mode or the storage behaviour the requirement asked for.

Requirement says…MechanismWhy this one
Only certain input values are allowed; a bad value must stop the run before any planvalidation block on the variableLives with the input and errors as the input is evaluated — before resources are planned whenever the condition uses input values only
A field is mandatory; another has a fallbackObject type with optional(type, default)The type itself enforces presence and fills defaults
One resource relies on a property of another objectprecondition on the consuming resourceErrors and documents the assumption next to the code that relies on it
An object or module output must guarantee something to every consumerpostcondition, or a precondition in an output blockErrors before consumers act on a bad value
Verify behaviour after apply without ever blocking unrelated changescheck block (plus HCP Terraform health assessments)Failures are warnings; health assessments re-test between runs
Hide a secret in CLI outputsensitive = trueRedacts display only; the value is still stored
A secret must never be written to state or a saved planEphemeral variables/resources and write-only argumentsValues are used during the run and never persisted
Every machine must install identical provider buildsCommitted .terraform.lock.hclRecords exact versions and checksums
A plan that destroys a critical resource must faillifecycle { prevent_destroy = true }Plan-time error; pair it with the provider's own deletion protection

The rest of this lesson takes each row in turn. A useful habit when reading a requirement: underline the words that state timing ("before a plan", "after apply", "between runs"), failure mode ("must stop", "must not block", "warn") and persistence ("never in state", "not in the console"). Those words decide between near-identical options.

Modelling requirements as typed inputs

A module's input variables are its contract, so requirements about what callers must and may provide belong in the variable's type, default and nullable settings. Get these three right and many bad inputs are rejected before any custom rule runs.

Required versus optional inputs

A variable is required exactly when it has no default. Any default — including "" and null — makes the input optional, so a module that must refuse to run without a region simply declares the variable with no default.

nullable

Variables are nullable by default (nullable = true): a caller can pass an explicit null, and the module then sees null — the default is not used. Setting nullable = false changes two things: null is never the value inside the module, and when a caller passes null for a variable that has a default, Terraform uses the default instead. This is how a child module falls back cleanly when a root passes through its own unset input.

DeclarationCaller omits itCaller passes null
No default, nullable = true (default)Error — requiredModule receives null
No default, nullable = falseError — requiredError
default = "standard", nullable true"standard"null (default ignored)
default = "standard", nullable = false"standard""standard"

A validation such as var.environment != "" does not catch null: null != "" is true. Close the null gap with nullable = false, not with string checks.

Object types and optional attributes

For structured inputs, declare an object({...}) type. Attributes listed plainly are required; the type conversion fails if a caller omits them. Mark an attribute optional(type) to let callers omit it — it then becomes a typed null — or optional(type, default) to have Terraform insert a default. The default is substituted both when the attribute is omitted and when it is set to null, so an attribute with a non-null default is never null inside the module. Terraform has no "zero value" fallback: an optional number with no default is null, not 0.

variable "services" {
  type = map(object({
    port     = number                  # required
    public   = optional(bool, false)   # omitted or null -> false
    replicas = optional(number)        # omitted -> null
  }))
}

optional() is valid only on attributes inside an object type — never as a variable's top-level type. A map of strings (map(map(string))) cannot require a key, so it cannot enforce a mandatory field.

Variable defaults never merge

A variable's default is used only when the caller provides no value for the whole variable. If a caller passes a partial object, that value replaces the default entirely and is checked against the type; a missing required attribute is an error, not a gap filled from the default. Per-attribute defaults belong in optional(type, default) inside the type.

Deriving naming and tagging conventions in locals

Conventions computed from inputs — a <org>-<env> name prefix, a standard tag map — belong in a locals block, because local values can be any expression over variables, other locals and resource attributes, and every resource in the module can reference them. That gives one place to change a convention.

Two alternatives look plausible and fail:

  • Variable defaults must be literal values. A default cannot reference other variables, locals or resources, so default = "${var.org}-${var.env}" or default = local.prefix is rejected by terraform validate.
  • Outputs pass values to the caller. Resources cannot reference their own module's outputs.

When callers may override a derived value, combine a null default with a local:

variable "name_prefix" {
  type    = string
  default = null   # optional override
}

locals {
  name_prefix = coalesce(var.name_prefix, "${var.org}-${var.env}")
  common_tags = {
    cost-center = var.cost_center
    environment = var.env
    owner       = var.owner
  }
}

A provider's default_tags block (for example in the AWS provider) can apply common tags to every resource it manages, but it cannot set names — the naming rule would still need a local.

Expressing environment differences as module inputs

A reusable module should not know which environment it runs in: each difference between environments should be an explicit input that the calling root module sets, such as single_nat_gateway, nat_gateway_per_az, instance_class or create_standby. HashiCorp's module guidance favours this because callers state what they need and the module stays independent of naming.

The common anti-patterns all embed the list of environments inside the module:

  • Branching on terraform.workspace or on var.environment == "prod" couples the module to naming conventions that differ between teams.
  • A lookup map keyed by environment name works today but needs a module edit for every new environment.
  • A single "is_prod" switch bundles unrelated behaviours. When a new environment needs prod's availability but dev's sizing, the switch cannot express it; separate inputs per behaviour can.
  • Copies of the module per environment drift apart and double maintenance.

When modelling, isolate exactly which dimension differs. If dev and prod span the same zones but differ in NAT redundancy, deriving NAT count from the zone list misses the requirement; a dedicated input does not.

Stable, plan-time-known instance identity

Predictable configuration means a change to one item touches only that item. With count, instances are addressed by position ([0], [1]…), so removing an element from the middle of a list shifts every later index and Terraform plans changes or replacements for unrelated objects. With for_each, instances are addressed by key, so choose keys that are unique, stable business identifiers — a project code, a service name, a repository name.

  • for_each accepts a map or a set of strings. A list of names becomes toset(var.names); a list of objects becomes a map with a for expression keyed by a unique attribute: { for b in var.buckets : b.name => b }.
  • A map keyed by index ({ for i, b in list : i => b }) uses for_each but keeps positional identity — the same ripple problem as count. Sorting a list does not help either.
  • Keys that change (list position, editable descriptions, generated values) make Terraform destroy and recreate the object under its new key. Non-unique keys fail with a duplicate-key error.

Keys must be known at plan time

Terraform must know how many instances exist and what they are called while planning. Attributes that only exist after apply — such as IDs of resources created in the same run — can be argument values but never keys; using them as keys fails with "Invalid for_each argument". Iterate over something whose keys come from configuration instead — for example, when security groups are created with for_each = var.tiers, their rules can use for_each = aws_security_group.tier with security_group_id = each.value.id: the keys are the tier names, the IDs are values. The rule is about where the unknown sits: any expression that puts an apply-time attribute into the keys fails the same way, and ordering hints such as depends_on never make a value known earlier. Tracing how such expressions resolve is covered in the lesson on variables, locals, outputs and for_each.

Per-run functions cause perpetual diffs

timestamp() and plantimestamp() return a new value on every run, so using them in tags or keys produces a change on every plan — and moving the call into a local changes nothing. A value that must be fixed at creation belongs in state: a time_static resource (hashicorp/time provider) records the time once and returns the same rfc3339 value on later plans. ignore_changes on the tag would also quiet the plan but hides real drift on that attribute.

Validation, preconditions, postconditions and check blocks

Terraform has four custom-condition mechanisms, and the difference that matters most is the failure mode: validation, preconditions and postconditions raise errors that stop the operation, while check blocks only warn.

MechanismWhere it livesWhen it is evaluatedOn failure
validationInside a variable blockWhen the input is evaluated, before resources that use it are planned (a 1.9+ condition that references a resource attribute unknown at plan time is checked during apply)Error — no plan
preconditionlifecycle of a resource or data source; inside an output blockBefore the object is planned/read, or before the output is exported (during plan when values are known, else apply)Error
postconditionlifecycle of a resource or data source (uses self)After the object is planned or, if values are unknown until then, after it is appliedError; dependent actions halt; no rollback
check with assertTop-level check blockAs the last step of every plan and applyWarning — the run continues

Validation rules

A validation block has a condition that must be true and an error_message. Allowed-value lists use contains(["dev", "staging", "prod"], var.environment); a string type cannot express an allowed list. Since Terraform 1.9, conditions may reference other variables and objects in the module, so cross-input rules such as var.max_size >= var.min_size are valid. To validate every element of a list, wrap a function that errors on bad input in can() and require every result to pass. Here every notification address must belong to the company domain:

validation {
  condition = alltrue([
    for e in var.alert_emails :
    can(regex("^[^@]+@example\\.com$", e))
  ])
  error_message = "Every address must be an @example.com address."
}

The general pattern is "per-element test, then all must hold" — alltrue over the results, or an empty list of failures. anytrue expresses "at least one", which is a different requirement. Where Terraform has a function that actually parses the value (for example cidrhost for network prefixes), testing it with can() is stronger than a loose textual check, which can pass values that are still invalid.

Postconditions never roll back

When a postcondition depends on values known only after apply (for example self.public_ip of a new instance), Terraform evaluates it after creating the object. A failure stops dependent resources, but the created object stays in place and is recorded in state — Terraform has no rollback. Fix the cause (here, the subnet's public-IP setting) and apply again.

Check blocks and scoped data sources

Check blocks are for verifying behaviour without blocking work — for example probing a load balancer's health endpoint. Put the probe inside the check as a scoped data source: errors from a data source nested in a check block are reported as warnings, while a top-level data source that fails (an unreachable URL) errors the whole plan, even though the assert itself would only have warned.

check "web_health" {
  data "http" "health" {
    url = "https://${aws_lb.web.dns_name}/health"
  }
  assert {
    condition     = data.http.health.status_code == 200
    error_message = "Health endpoint is not returning 200."
  }
}

Health assessments in HCP Terraform extend this between runs: enabled on a workspace, they run drift detection and continuous validation, re-evaluating check blocks and pre/postconditions on a schedule and notifying the team when one starts failing. They never stop a plan, and they are not on everywhere by default: they are a per-workspace (or organization-wide) setting, and which HCP Terraform plans include them is set by HashiCorp's current pricing. terraform validate, by contrast, checks syntax and internal consistency only — it reads no data sources and contacts no infrastructure — and terraform test runs separate test runs that do not guard a real plan or apply.

Choosing where a condition belongs: assumption or guarantee

Put a condition where its meaning lives: a precondition states an assumption one consumer makes; a postcondition (or an output precondition) states a guarantee an object or module makes to everyone who uses it. The error message then names the right object and the rule fires only where it should.

  • If only one instance type in the configuration requires an x86_64 image, put a precondition in that instance's lifecycle block. A postcondition on the shared image data source would claim the architecture for every consumer and break the resources that legitimately use ARM images.
  • If a module promises callers that the subnet it returns is in the expected VPC, put a precondition inside the output block. It is evaluated before the value is exported, so the module enforces its own guarantee once instead of every downstream resource repeating it.
  • If the rule is about an input alone, a validation block is the natural home; a validation that tests a data source attribute describes the input, not the resource that needs the setting.
resource "aws_instance" "legacy_app" {
  ami           = data.aws_ami.app.id
  instance_type = "m5.large"

  lifecycle {
    precondition {
      condition     = data.aws_ami.app.architecture == "x86_64"
      error_message = "The legacy app build needs an x86_64 AMI."
    }
  }
}

What sensitive does — and does not — protect

sensitive = true controls display: Terraform redacts the value in plan and apply output and carries the mark through every expression derived from it — string templates, locals, function results. It does not change storage: a sensitive value used in a resource argument is written to state in plain text, and saved plan files (terraform plan -out) contain it too. Terraform does not encrypt individual values; protecting state and plan artefacts at rest is the job of the backend (encryption, access control) and the pipeline.

Outputs must declare sensitivity

If an output's value derives from a sensitive value, the output must set sensitive = true; otherwise planning fails with an error that the output refers to sensitive values. Terraform does not quietly mark the output for you, and it never partially redacts a string. A sensitive root output is still stored in state and can be read on purpose with terraform output or terraform output -raw — which is exactly right when a later pipeline step needs the value but the apply summary must not print it.

Sensitive values and for_each

for_each keys become visible instance addresses, so Terraform rejects sensitive values as for_each arguments. Sensitivity propagates, so toset(keys(var.users)) on a sensitive map is still sensitive and fails the same way, and wrapping the whole map in nonsensitive() strips redaction from the secrets as well. The clean design separates identifiers from secrets: iterate over a non-sensitive map (names and roles) and read each password from a separate sensitive map with each.key. Reserve nonsensitive() for values you have confirmed are not secret.

Keeping secrets out of state: ephemeral values and write-only arguments

When the requirement is that a secret must never be stored — not just hidden — use ephemeral values (Terraform 1.10+) and write-only arguments (Terraform 1.11+). Both exist during the run and are never written to state or plan files.

FeatureShown in CLI outputStored in stateStored in saved plan
Ordinary valueYesYesYes
sensitive = trueRedactedYesYes
Ephemeral variable / resource / child outputNot shown (only allowed in contexts that are never printed or persisted)NoNo
Write-only argument (e.g. password_wo)Not shownNo (only its version argument)No

Ephemeral inputs, resources and outputs

  • Ephemeral variables (ephemeral = true) are omitted from plan and state files, so a value used with a saved plan must be supplied again at apply. How a value is delivered (-var, TF_VAR_) does not change storage — only ephemeral does.
  • Ephemeral resources (an ephemeral block, for example ephemeral "aws_secretsmanager_secret_version" or ephemeral "random_password") fetch or generate a value for the current run only. A data source reading the same secret stores the result in state, even when wrapped in sensitive(), and terraform_remote_state just copies a secret from one state file into another.
  • Ephemeral outputs are allowed only in child modules, to pass ephemeral values up to the caller. Root module outputs are persisted in state for terraform output and remote-state readers, so ephemeral = true on a root output is an error.
  • Ephemeral values may flow only into ephemeral contexts: provider configuration blocks, provisioner and connection blocks, locals, ephemeral variables and child outputs, other ephemeral resources, and write-only arguments. Ordinary resource arguments reject them, and so does a managed resource's for_each.

Write-only arguments and their version argument

A write-only argument, offered by providers that support it and conventionally suffixed _wo, accepts a value (ephemeral or not), passes it to the provider, then discards it. Because Terraform keeps no copy — not even a hash — it cannot show a diff when the value changes and cannot detect drift in it. Providers pair each one with a version argument (for example password_wo_version) that is stored; incrementing it is the signal to send the new value in place. Changing only the value produces "No changes", so the version bump is the rotation step; lifecycle settings that hide or force changes do not substitute for it.

ephemeral "random_password" "db" {
  length = 24
}

resource "aws_db_instance" "main" {
  # ...
  password_wo         = ephemeral.random_password.db.result
  password_wo_version = 1   # bump to rotate
}

Both halves matter: a managed random_password resource writes its result to state even if the database uses a write-only argument, and an ephemeral generator cannot feed an ordinary password argument. Secret rotation and secrets-manager integration are developed further in the security and governance domain.

Reproducible builds and destruction guards

Predictable configuration also means every engineer and pipeline installs the same dependencies and that dangerous plans fail loudly.

The dependency lock file

Version constraints in required_providers describe acceptable ranges; .terraform.lock.hcl records the exact provider versions and package checksums selected within them. Commit it, and terraform init on every machine reuses those selections until someone deliberately runs terraform init -upgrade. Do not commit the .terraform directory — it is a local, platform-specific cache.

Checksums are per platform. Installing from a network or filesystem mirror records checksums only for the platform that ran init, so a lock file created on darwin_arm64 may lack linux_amd64 entries, and a CI run with terraform init -lockfile=readonly (which forbids changes to the file) then fails verification. Pre-populate every platform the team uses and commit the result:

terraform providers lock \
  -platform=darwin_arm64 \
  -platform=linux_amd64

Exact = version constraints do not replace checksums; Terraform still verifies packages against the lock file.

required_version in shared modules

required_version constrains the Terraform CLI, and Terraform checks it in the root module and every child module. A shared module that uses a feature introduced in a given release should declare a lower bound such as required_version = ">= 1.9.0": older CLIs get a clear version error, newer ones keep working. The pessimistic form ~> 1.9.0 adds an upper bound (1.9.x only), and a floor set at the latest release excludes capable versions. The lock file records providers only — never the CLI version.

prevent_destroy versus provider deletion protection

lifecycle { prevent_destroy = true } makes Terraform reject any plan that would destroy the resource, including a replacement, so a reviewer must remove the guard deliberately. It lives in the resource block, though: if someone deletes the block, the guard goes with it and Terraform plans a normal destroy. A provider-side setting such as deletion_protection = true on aws_db_instance protects the real object — the API refuses the delete during apply — but does not fail the plan. Requirements that cover both failure modes need both.

SettingFails the plan?Survives deleting the resource block?
prevent_destroy = trueYesNo
Provider deletion protectionNo — refused by the API at applyYes
create_before_destroyNo — only reorders a replacementNo
ignore_changesNo — only ignores listed attribute editsNo

A removed block with lifecycle { destroy = false } (Terraform 1.7+) is the deliberate way to stop managing a resource without destroying it — useful when someone chooses to remove it, but it does nothing when a block is simply deleted.

Tip. Expect requirement-driven scenarios where several options are real Terraform features and the stem's constraints decide: whether a rule must stop the run or only warn, fire before planning or after apply, live on the input, the consuming resource or the output, and whether a secret may be stored in state or a saved plan. Input-design items show variable declarations and ask what a caller's value becomes — omitted versus null, optional attributes with and without defaults, defaults that do not merge, nullable. Others ask why a configuration fails (sensitive values in outputs or for_each, ephemeral root outputs, defaults referencing variables, unknown for_each keys) or what changes after an apply (failed postconditions, write-only arguments that show no diff). Multi-select items pair two facts, such as display versus storage of sensitive values or a plan-time guard plus an object-level guard. Version and lock-file items cover reproducible provider installs across platforms and minimum CLI versions for shared modules.

Key takeaways
  • Read requirements for timing, failure mode and persistence — those words decide which Terraform mechanism fits
  • No default means required; nullable = false rejects null on required inputs and makes null fall back to the default on optional ones
  • optional(type, default) fills omitted and null object attributes; a variable default is replaced wholesale by any caller value and never merges
  • Variable defaults must be literals: derive conventions in locals (coalesce for overridable values) — modules cannot read their own outputs
  • Environment differences are explicit inputs set by each root module, never workspace-name or environment-name checks inside the module
  • Key for_each by stable, unique identifiers known at plan time; apply-time IDs are values, not keys; fixed timestamps belong in state (time_static)
  • Validation (1.9+ may reference other variables), preconditions and postconditions error; check blocks and their scoped data sources only warn; postconditions never roll back
  • sensitive redacts display and propagates, but values remain in state and saved plans; outputs built from sensitive values must be marked sensitive
  • Ephemeral values and write-only arguments are never persisted; root outputs cannot be ephemeral; bump the _wo_version argument to send a new write-only value
  • Commit .terraform.lock.hcl (lock every platform with terraform providers lock -platform), use >= required_version in shared modules, and pair prevent_destroy with provider deletion protection

Frequently asked questions

What is the difference between a precondition, a postcondition and a check block in Terraform?

A precondition states an assumption a resource, data source or output relies on and is checked before that object is planned or exported. A postcondition states a guarantee about an object, using self, and is checked after it is planned or applied. Both raise errors that stop the run and halt dependent actions, and neither rolls back changes already made. A check block asserts conditions at the end of every plan and apply but only produces warnings, so it never blocks unrelated changes; HCP Terraform health assessments can re-run checks between runs.

Does marking a Terraform variable sensitive keep it out of the state file?

No. sensitive = true redacts the value, and anything derived from it, in plan and apply output, but Terraform still writes the value to state in plain text and includes it in saved plan files. To keep a secret out of state and plans, use an ephemeral variable or ephemeral resource and pass it to a write-only argument, and protect state at rest through the backend.

How do I rotate a password set through a Terraform write-only argument?

Change the value and increment the paired version argument, such as password_wo_version. Terraform never stores the write-only value, so changing the value alone shows no changes in the plan. The version argument is stored in state, so bumping it produces an in-place update that sends the new value to the provider without replacing the resource.

Why does Terraform ignore my variable default when a caller passes part of an object?

A variable default is used only when the caller provides no value at all. Any value the caller passes replaces the default entirely and must match the type, so a missing required attribute is an error rather than being filled from the default. Declare per-attribute defaults inside the type with optional(type, default), which Terraform applies when the attribute is omitted or set to null.

Can a Terraform root module output be ephemeral?

No. Ephemeral outputs are allowed only in child modules, where they pass ephemeral values up to the calling module. Root module outputs are stored in state so terraform output and remote state readers can use them later, so Terraform rejects ephemeral = true on a root output. Mark it sensitive instead if it must be stored but not printed, or have a later step read the secret from a secrets manager.

Why should I commit the .terraform.lock.hcl file?

The dependency lock file records the exact provider versions and package checksums that terraform init selected, so every engineer and pipeline installs identical provider builds until someone runs terraform init -upgrade. Checksums are per platform, so teams on several operating systems should run terraform providers lock with a -platform flag for each one and commit the result. The .terraform directory itself is a local cache and should not be committed.

Does prevent_destroy protect a resource if its block is deleted from the configuration?

No. prevent_destroy is part of the resource block, so it makes any plan that would destroy or replace the resource fail only while that block exists. If the block is deleted, the guard is gone and Terraform plans a destroy. A provider-side setting such as deletion_protection on an AWS database protects the real object by making the delete API call fail, so critical resources typically use both.

Source

This lesson covers the "Scalable Terraform configuration design" domain of the official Terraform Pro 002 exam guide. Vendors revise their guides — check the source for the current version.

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

Spotted a mistake in this lesson?