Terraform

Using Variables and Expressions in Terraform for Flexible Configurations

AGAnurag Gupta07 Apr 2025 Β· Updated 04 Oct 2026 Β· 7 min read
Using Variables and Expressions in Terraform for Flexible Configurations

Quick answer: Terraform variables let you pass values into a configuration β€” region, instance size, environment name β€” so the same code works for dev, staging and production. Expressions let you compute values from those inputs using references, conditionals, loops and more than 100 built-in functions such as cidrsubnet, format and lookup. Together they turn a one-off script into a reusable template.

This guide covers every variable type, the ways values can be supplied and which one wins, local values, the expression features you will use daily, and a hands-on example that builds a VPC with subnets calculated automatically. It also updates the old "${var.x}" style that still appears in many tutorials to modern Terraform 1.x syntax.

Declaring input variables

An input variable is declared with a variable block, usually in variables.tf. Only the name is required, but production code should always set type and description:

variable "region" {
  type        = string
  description = "AWS region where resources are created"
  default     = "ap-south-1"
}

variable "instance_count" {
  type        = number
  description = "How many web servers to launch"
  default     = 2

  validation {
    condition     = var.instance_count >= 1 && var.instance_count <= 10
    error_message = "instance_count must be between 1 and 10."
  }
}

variable "allowed_cidrs" {
  type        = list(string)
  description = "CIDR ranges allowed to reach the load balancer"
  default     = ["0.0.0.0/0"]
}

variable "tags" {
  type        = map(string)
  description = "Tags applied to every resource"
  default     = {}
}

variable "db_password" {
  type      = string
  sensitive = true
}

A variable without a default is required; Terraform will prompt for it or fail in CI. Marking a variable sensitive = true hides its value from plan output and logs. The validation block (Terraform 0.13+) catches bad input before any API call is made.

Variable types at a glance

Type Example value Typical use
string "ap-south-1" Names, regions, IDs
number 3, 0.5 Counts, sizes, ports
bool true Feature toggles such as enable_nat
list(T) ["a", "b"] Ordered items: subnets, AZs
set(T) toset(["a", "b"]) Unique items for for_each
map(T) { env = "dev" } Key/value pairs: tags, lookups
object({...}) { name = "x", size = 20 } Structured input with fixed attributes
any anything Avoid except in generic modules

Object types are especially useful in modules: one variable such as database = object({ engine = string, size = number }) is clearer than five separate strings and numbers.

Supplying values and the precedence order

Terraform looks for variable values in several places. When the same variable is set more than once, the later source in this list wins:

  1. Environment variables named TF_VAR_<name>, for example export TF_VAR_region=ap-south-1.
  2. A terraform.tfvars or terraform.tfvars.json file in the working directory.
  3. Any *.auto.tfvars files, in alphabetical order.
  4. -var and -var-file flags on the command line, in the order given.

A common team pattern is one file per environment β€” dev.tfvars, prod.tfvars β€” applied with terraform apply -var-file=prod.tfvars. Secrets such as db_password should come from TF_VAR_ environment variables injected by the CI system or a secrets manager, never from a committed file.

Local values: named expressions

locals are not inputs; they are computed values you name once and reuse, which keeps long expressions out of resource blocks:

locals {
  name_prefix = "tkh-${var.environment}"

  common_tags = merge(var.tags, {
    Environment = var.environment
    ManagedBy   = "terraform"
  })

  is_prod = var.environment == "prod"
}

Reference them as local.name_prefix. If you find yourself pasting the same merge() into six resources, that is a local waiting to be written.

Expressions you will use every day

Since Terraform 0.12, expressions are written directly, not inside "${...}". The interpolation syntax is only needed when you embed a value inside a larger string.

  • References – var.region, local.is_prod, aws_vpc.main.id, module.network.subnet_ids.
  • String templates – "${local.name_prefix}-web".
  • Conditionals – instance_type = local.is_prod ? "t3.large" : "t3.micro".
  • for expressions – [for s in var.subnets : upper(s)] or { for k, v in var.tags : k => lower(v) }.
  • Splat – aws_instance.web[*].id collects one attribute from every instance created with count.
  • Functions – length(), join(), format(), lookup(), try(), cidrsubnet(), templatefile(), jsonencode() and many more. Terraform has no user-defined functions; combine the built-ins or move logic into a module.

Try any expression interactively with terraform console. Type cidrsubnet("10.0.0.0/16", 8, 3) and it answers "10.0.3.0/24" β€” far faster than running a plan to check.

Hands-on: a VPC with calculated subnets

The example below uses count, cidrsubnet and a data source so adding a third subnet is a one-number change:

variable "vpc_cidr" {
  type    = string
  default = "10.0.0.0/16"
}

variable "subnet_count" {
  type    = number
  default = 2
}

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr
  tags       = merge(local.common_tags, { Name = "${local.name_prefix}-vpc" })
}

resource "aws_subnet" "private" {
  count = var.subnet_count

  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = merge(local.common_tags, {
    Name = format("%s-private-%02d", local.name_prefix, count.index + 1)
  })
}

output "private_subnet_ids" {
  value = aws_subnet.private[*].id
}

cidrsubnet(var.vpc_cidr, 8, count.index) adds 8 bits to the /16 prefix, giving /24 subnets numbered 0, 1, 2. The availability zone comes from a data source rather than string concatenation like "${var.region}a", which fails in regions whose zones are not lettered consecutively. For the dependency implications of referencing aws_vpc.main.id, see Understanding Resource Dependencies and Ordering in Terraform.

Variable and expression mistakes to avoid

  1. Legacy interpolation everywhere. "${var.region}" still works but terraform fmt flags it. Write var.region; use ${} only inside longer strings.
  2. Omitting type. Without it a user can pass a list where you expect a string, and the error surfaces deep inside a resource instead of at the variable.
  3. Committing secrets in terraform.tfvars. Use TF_VAR_ variables, a secrets manager data source, or HCP Terraform variable sets.
  4. Using count for named things. Removing item 0 from a list shifts every index and forces replacement of everything. Use for_each with a map or set so each resource has a stable key.
  5. Building availability zones by string concatenation. Query aws_availability_zones instead; not every region has a, b, c.

Variables are also the interface of every module you publish; Terraform Module Versioning and Publishing covers how to evolve that interface without breaking consumers.

Frequently asked questions

What is the difference between a variable and a local?

A variable is an input set from outside the configuration. A local is an internal named expression computed from variables, resources or other locals. Locals cannot be overridden by users.

Can I use a variable inside another variable’s default?

No. Defaults must be literal values. Compute derived values in locals instead.

How do I pass a list on the command line?

Use HCL syntax inside quotes: terraform apply -var='allowed_cidrs=["10.0.0.0/8","192.168.0.0/16"]'. For anything complex, a .tfvars file is easier to read.

Does marking a variable sensitive encrypt it?

No. It only redacts the value from CLI output. The value is still stored in plain text in the state file, which is why state must live in an encrypted, access-controlled backend.

Key takeaways

  • Declare every variable with a type and description; add validation for anything with constraints.
  • Supply values via .tfvars files per environment and TF_VAR_ variables for secrets.
  • Use locals to name repeated expressions, and terraform console to test them.
  • Prefer for_each over count for named resources, and data sources over string-built IDs.
  • Write modern expressions directly; reserve ${} for string templates.

Want to practise building flexible, multi-environment Terraform configurations on real AWS and Azure accounts? Our DevOps course covers Terraform end to end with live projects, mentor support and placement assistance. Prefer video? Follow along on our YouTube channel.

AG
Written byAnurag Gupta

Part of the Techknowledgehub team of industry mentors, writing practical guides to help you build a job-ready tech career.

More articles by Anurag Gupta β†’
Keep reading

Related articles

Leave a Reply