Skip to main content
RunBook Academy

TerraformXIII · Variables, Outputs, and LocalsProduction Terraform

Expressions for Production Configurations

Intermediate⏱ ~12 minbash

What you'll learn

  • Write default expressions that are simple, readable, and predictable
  • State the reference scope available inside `default = ...`
  • Lift complex default expressions into a local or a data source
  • Validate environment-specific defaults with a `validation` block
  • Recognise the failure modes of clever defaults in production

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

A default = ... clause in a variable block is an expression, not a constant. It can reference other variables, call functions, and use conditionals. The line between “useful default” and “obscure default” is where production operators lose the ability to predict what the configuration will do.

A real production incident: the team wrote a default expression that walked a list of regions, picked the cheapest, and returned the matching AMI ID. The default compiled. The default was also wrong: the “cheapest” region was not the team’s contracted region, and the AMI lookup ran against the wrong partition. The apply provisioned 200 machines in the wrong account. The fix was to lift the logic out of the default and require the caller to pass an explicit AMI ID.

What a default can reference

The reference scope for a default expression is narrower than for a resource argument and narrower than for a validation block.

   variable default MAY reference
            |
            +-- other variables in the same module
            |
            +-- literals (string, number, bool, list, map)
            |
            +-- Terraform built-in functions
            |
            +-- type constructors (list(), map(), tomap(), etc.)
            |
   variable default MAY NOT reference
            |
            +-- resources (not yet known)
            |
            +-- data sources (not yet resolved)
            |
            +-- locals (fragile — locals may be declared later)
            |
            +-- outputs from other modules
            |
            +-- count or for_each from the same module
# CONFIGURATION

variable "environment" {
  type        = string
  default     = "production"
  description = "Deployment environment."
}

variable "instance_count" {
  type        = number
  default     = var.environment == "production" ? 6 : 2
  description = "Number of app instances. Higher in production."
}

variable "common_tags" {
  type = map(string)
  default = {
    ManagedBy  = "terraform"
    Env        = var.environment
    CostCentre = "platform"
  }
  description = "Tags applied to every resource in this module."
}

variable "availability_zones" {
  type        = list(string)
  default     = ["eu-west-2a", "eu-west-2b", "eu-west-2c"]
  description = "AZs to spread subnets across."
}

The instance_count default shows a conditional that is one line and obvious. The common_tags default shows a map literal that references a variable. Both are within the complexity cap. Anything more elaborate should be lifted.

The complexity cap

The default is evaluated every time the configuration is loaded. It is also evaluated by every terraform plan, by every terraform console session, and by every IDE that parses the configuration. A default that takes a week to understand is a default that ships with bugs.

Complexity ladder (simplest to most complex):
  literal                    -> "production"
  literal + var reference    -> var.environment
  conditional                -> var.environment == "production" ? 6 : 2
  map literal + var          -> { Env = var.environment }
  function call              -> format("%s-%s", var.env, var.region)
  for expression             -> [for az in var.azs : "${az}-${var.region}"]
  nested conditionals        -> env == "prod" ? (region == "eu" ? 6 : 4) : 2
  list comprehension + cond  -> [for x in xs : x if x.enabled]
  data source reference      -> (not allowed in default)

Production rule: keep the default readable in five seconds. If the reader has to think, lift the expression to a local in the calling module and pass the result in.

# CONFIGURATION — calling module

locals {
  instance_count = var.environment == "production" ? 6 : 2
  instance_type  = var.environment == "production" ? "m5.large" : "t3.medium"
}

module "app" {
  source         = "../modules/app"
  environment    = var.environment
  instance_count = local.instance_count
  instance_type  = local.instance_type
}

The complex logic now lives in the calling module’s locals, where the reader expects to find derivation. The module receives a simple, typed input. The contract is clean.

Validation on environment-specific defaults

A default that varies by environment must be validated. The validation runs at plan time and catches a bad value before any provider API call.

# CONFIGURATION

variable "environment" {
  type        = string
  default     = "production"
  description = "Deployment environment."

  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "Environment must be one of: dev, staging, production."
  }
}

variable "log_level" {
  type        = string
  default     = "info"
  description = "Application log level."

  validation {
    condition     = contains(["debug", "info", "warn", "error"], var.log_level)
    error_message = "Log level must be one of: debug, info, warn, error."
  }
}

The validation block can reference other variables. The default cannot. Keep the two responsibilities distinct: defaults set values; validations restrict them.

WhyThisMatters

WhyThisMatters The default expression is the most-read line in a variable block. It is the first thing the operator looks at when they ask “what does this default to?”. If the answer is not obvious, the operator does the wrong thing in production.

Failure modes

  1. Default references a local declared later. The configuration fails to parse or evaluate; the error message points at the local block, not the variable block. The fix is to lift the local out of the default.

  2. Default references a resource. The error is Reference to resource in variable default. The fix is to use a data source or to pass the value in explicitly.

  3. Default uses a function not in the allowed set. Some functions (formatdate, timestamp) are not legal in default. The plan errors with Function not allowed in default value.

  4. Default is too complex to predict. The operator cannot tell what the value will be without running terraform console. The fix is to lift the logic or require an explicit value.

  5. Default produces a different type than type declares. A conditional returns 6 (number) in one branch and "6" (string) in the other. The plan errors with a type mismatch.

  6. Default references a nullable variable that is unset. The nullable = true variable is null; the default expression evaluates against null and produces a runtime type error.

Recovery

When a default fails to evaluate or produces a wrong value:

# READ-ONLY — print the resolved value
terraform console
> var.instance_count
6

# READ-ONLY — verify the source of each variable
terraform plan
# Look for "var.environment" in the plan output

If the default is too complex, refactor: lift the expression to a local in the calling module, add the local’s name to the module interface, and document the new input.

Production guidance

  • Keep defaults simple. A default is a literal, a reference, or a one-line conditional.
  • Validate environment-shaped variables with validation blocks.
  • Document the default in description. The description explains what the default is and when to override it.
  • Lift complex expressions to a locals block in the calling module. The module receives a simple input.
  • Do not reference resources or data sources in defaults. The evaluation order does not allow it.
  • Audit defaults quarterly. Replace clever defaults with required inputs.

What comes next

The XIII-Variables module concludes with a synthesis lesson on input discipline across the whole configuration — the contract that holds variables, locals, and outputs together.

Verification

  1. Can a variable default reference a resource attribute? Why or why not?
  2. The default expression needs a for loop to build a list of AZs. What is the right place for that expression, and why?
  3. A validation block on var.environment references data.aws_caller_identity.current.account_id. Does the validation run at plan time? Why or why not?
  4. The default for instance_count differs between production and non-production. What is the right way to express this and how do you keep the default readable?

Knowledge check · 7 questions

  1. Q1. What may a variable `default` expression reference?

  2. Q2. Variable `default` expressions are evaluated before data sources are resolved, so a default may not reference a `data` source.

  3. Q3. What is the recommended complexity cap for a `default` expression in production?

  4. Q4. A default needs a `for` expression to build a list of AZs from a region name. What is the right pattern?

  5. Q5. Which of the following may a variable `default` reference? (Select all that apply.)

  6. Q6. A default references a `nullable = true` variable that the operator has not set. What happens?

  7. Q7. A team wants region-specific instance types baked into a variable default. The default needs an `if`/`else` that switches on `var.region`. What is the right pattern?

Passing score: 75%. Answers are checked in this browser.