Terraform Course
Variables
You have used variables since Lesson 1. But there is far more to them than a name, a type, and a default. This lesson covers the complete variable system — every type, every way to supply a value, validation rules, sensitive marking, complex object types, and the precedence chain that determines which value wins when multiple sources supply the same variable.
This lesson covers
All primitive and complex types → Variable validation → Sensitive variables → The full precedence chain → Complex object and collection types → Real project applying everything
Setting Up
Create a fresh project. This lesson is entirely about variables.tf — we build it up section by section. The main.tf at the end applies every variable type in a real AWS configuration.
mkdir terraform-lesson-11
cd terraform-lesson-11
touch versions.tf variables.tf locals.tf main.tf outputs.tf .gitignore
Add this to versions.tf:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# Provider reads the region from a variable — no hardcoded values anywhere in the project
provider "aws" {
region = var.region
}
Run terraform init. Once the provider downloads, continue below.
Primitive Types
Terraform has three primitive types: string, number, and bool. These are the building blocks for every variable you write. Terraform will attempt to coerce values between compatible types — passing "2" to a number variable works. Passing "yes" to a bool variable works. Passing "hello" to a number variable fails with a clear error.
We are writing the first section of variables.tf — three primitives, each demonstrating the correct declaration pattern with description, type, default, and a validation rule where it makes sense.
New terms:
- string — a sequence of Unicode characters. The most common variable type. Used for names, regions, AMI IDs, ARNs, environment names, and any other text value. String values are always wrapped in double quotes in HCL.
- number — an integer or floating-point numeric value. Used for port numbers, instance counts, capacity settings, timeouts. Terraform internally represents all numbers as 64-bit floating point — integers are a subset.
- bool — a true or false value. Used to enable or disable features, toggle behaviour, or control conditional expressions. In Terraform,
trueandfalseare written without quotes — they are keywords, not strings. - can() function — a built-in function used inside validation conditions. It evaluates an expression and returns true if the expression succeeds without error, or false if it throws any error. Useful for validating format patterns — for example, checking whether a string can be parsed as a valid CIDR block.
Add this to variables.tf:
# ─── PRIMITIVE TYPES ────────────────────────────────────────────────────────
variable "region" {
description = "AWS region to deploy all resources into"
type = string
default = "us-east-1"
# Validates that the value looks like an AWS region identifier
# regex() returns an error if the pattern does not match — can() converts that to false
validation {
condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]$", var.region))
error_message = "region must be a valid AWS region — e.g. us-east-1, eu-west-2."
}
}
variable "environment" {
description = "Deployment environment — controls sizing, redundancy, and naming"
type = string
default = "dev"
# contains() checks whether the value exists in the allowed list
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
variable "instance_count" {
description = "Number of EC2 instances to create — must be between 1 and 10"
type = number
default = 1
# Numeric validation — enforce a sensible range
validation {
condition = var.instance_count >= 1 && var.instance_count <= 10
error_message = "instance_count must be between 1 and 10 inclusive."
}
}
variable "enable_detailed_monitoring" {
description = "Enable CloudWatch detailed monitoring on EC2 instances — adds cost"
type = bool
default = false # Off by default — enable explicitly for prod
}
$ terraform apply -var="region=invalid-region"
╷
│ Error: Invalid value for input variable
│
│ on variables.tf line 12, in variable "region":
│ 12: validation {
│
│ The given value is not valid for variable "region":
│ region must be a valid AWS region — e.g. us-east-1, eu-west-2.
╵
$ terraform apply -var="instance_count=15"
╷
│ Error: Invalid value for input variable
│
│ on variables.tf line 38, in variable "instance_count":
│ 38: validation {
│
│ The given value is not valid for variable "instance_count":
│ instance_count must be between 1 and 10 inclusive.
╵What just happened?
- Both validation errors fired before any planning occurred. Terraform checks all variable values against their validation rules immediately after reading them — before building the dependency graph, before making any API calls. Bad inputs are rejected at the earliest possible point.
- can(regex(...)) validates string format. The
regex()function returns an error if the pattern does not match the string.can()wraps that call and converts the error into a boolean —trueif regex matched,falseif it threw an error. This pattern is how you validate URL formats, CIDR notation, ARN structure, and any other format-constrained string. - The && operator combines two conditions in one validation.
var.instance_count >= 1 && var.instance_count <= 10enforces both a minimum and maximum in a single condition block. Both must be true for validation to pass. For disjunctive conditions — either this or that — use||instead.
Collection Types
Collection types hold multiple values. Terraform has three: list, set, and map. Lists are ordered and allow duplicates. Sets are unordered and deduplicate values automatically. Maps are key-value pairs where the key is always a string.
We are adding collection type variables to variables.tf — a list of allowed ingress ports, a set of availability zones, and a map of instance roles to instance types. These will drive the security group and EC2 instances in main.tf later.
New terms:
- list(type) — an ordered sequence of values all of the same type. Referenced by index:
var.ingress_ports[0]is the first element. Used withcountor dynamic blocks to create one resource per element. - set(type) — an unordered collection of unique values. Terraform automatically removes duplicates. Used with
for_eachto create one resource per unique value. Cannot be indexed — elements have no guaranteed order. - map(type) — a collection of key-value pairs where all values are the same type. The key is always a string. Referenced by key:
var.instances["primary"]. Used withfor_eachto create one resource per key. - length() function — returns the number of elements in a list, set, or map. Used in validation rules to enforce size constraints — for example, ensuring at least one and no more than three availability zones are specified.
Append these to variables.tf:
# ─── COLLECTION TYPES ───────────────────────────────────────────────────────
variable "ingress_ports" {
description = "List of TCP ports to open inbound on the web security group"
type = list(number)
default = [80, 443] # HTTP and HTTPS — add 8080 for dev if needed
# length() counts elements — enforce at least one port and no more than ten
validation {
condition = length(var.ingress_ports) >= 1 && length(var.ingress_ports) <= 10
error_message = "ingress_ports must contain between 1 and 10 port numbers."
}
}
variable "availability_zones" {
description = "Set of availability zones to deploy subnets into — duplicates removed automatically"
type = set(string)
default = ["us-east-1a", "us-east-1b"] # Two AZs for basic redundancy
validation {
condition = length(var.availability_zones) >= 1 && length(var.availability_zones) <= 3
error_message = "availability_zones must contain 1 to 3 AZ names."
}
}
variable "instances" {
description = "Map of instance role name to EC2 instance type — drives for_each in main.tf"
type = map(string)
# In dev, one small instance. In prod, pass a larger map via tfvars.
default = {
primary = "t2.micro" # Primary web server
}
}
$ terraform console # Lists are ordered — access by index > var.ingress_ports[0] 80 > var.ingress_ports[1] 443 # Sets are unordered — convert to list to sort, use tolist() or toset() > var.availability_zones toset([ "us-east-1a", "us-east-1b", ]) # Maps accessed by key > var.instances["primary"] "t2.micro" # length() works on all collection types > length(var.ingress_ports) 2 > length(var.instances) 1
What just happened?
- Lists are accessed by numeric index, sets are not.
var.ingress_ports[0]returns 80 because lists are ordered.var.availability_zonesshowstoset([...])— the values exist but have no guaranteed index. If you need to iterate over a set in order, convert it first withtolist(sort(var.availability_zones)). - Maps are accessed by string key.
var.instances["primary"]returns"t2.micro". This is howeach.valueworks inside afor_eachresource — Terraform iterates the map and makes each value available aseach.valuefor the current key. - terraform console evaluates expressions against real variable values. Every expression you would write in a resource block can be tested here first. This is especially useful when building complex
forexpressions, calling functions, or debugging validation conditions before running a full plan.
Object and Tuple Types
Objects and tuples are structural types — they group values of different types together. An object is like a struct: a fixed set of named attributes each with their own type. A tuple is a fixed-length list where each position can hold a different type. In practice, objects are far more commonly used.
We are adding an object variable for database configuration — grouping the instance class, allocated storage, and engine version into one structured input rather than three separate variables. This makes the configuration cleaner and makes it impossible to accidentally supply a storage size without also supplying an engine version.
New terms:
- object({...}) — a structural type with named attributes. Each attribute has its own type constraint. When you pass a value for an object variable, every attribute in the type definition must be present in the value — Terraform rejects partial objects.
- optional(type, default) — marks an attribute inside an object type as optional. If the caller does not supply that attribute, Terraform uses the provided default instead of throwing a missing-attribute error. This makes object variables more flexible without losing type safety.
- var.object_variable.attribute — the syntax for accessing an attribute of an object variable.
var.database_config.instance_classaccesses the instance_class attribute of the database_config object.
Append this to variables.tf:
# ─── OBJECT TYPE ────────────────────────────────────────────────────────────
variable "database_config" {
description = "RDS database configuration — groups all DB settings into one structured input"
type = object({
instance_class = string # RDS instance size e.g. db.t3.micro
allocated_storage = number # Storage in GB
engine_version = string # PostgreSQL version e.g. "15.3"
multi_az = optional(bool, false) # Multi-AZ for high availability — defaults to off
deletion_protection = optional(bool, false) # Prevent accidental DB deletion
})
default = {
instance_class = "db.t3.micro"
allocated_storage = 20
engine_version = "15.3"
# multi_az and deletion_protection use their optional() defaults
}
validation {
# allocated_storage must be at least 20GB — the AWS minimum for most engines
condition = var.database_config.allocated_storage >= 20
error_message = "allocated_storage must be at least 20 GB."
}
}
$ terraform console
# Access individual attributes with dot notation
> var.database_config.instance_class
"db.t3.micro"
> var.database_config.allocated_storage
20
> var.database_config.multi_az
false
# optional() filled in the default — even though we never provided multi_az
> var.database_config.deletion_protection
false
# Test validation — this should fail
$ terraform apply -var='database_config={"instance_class":"db.t3.micro","allocated_storage":10,"engine_version":"15.3"}'
╷
│ Error: Invalid value for input variable
│
│ The given value is not valid for variable "database_config":
│ allocated_storage must be at least 20 GB.
╵What just happened?
- optional() filled in missing attributes automatically. The default value did not include
multi_azordeletion_protection— but the object type declared them asoptional(bool, false). Terraform applied the optional defaults, so both attributes exist on the object with valuefalse. Withoutoptional(), omitting any attribute from the default would throw a type mismatch error. - Validation accessed a nested attribute.
var.database_config.allocated_storagedrills into the object to check the storage value. This is the same dot notation you use in resource blocks — validation rules can reference nested attributes of object variables. - Passing an object via -var requires JSON syntax. On the command line, complex variable values are passed as JSON strings.
-var='database_config={"instance_class":"db.t3.micro",...}'— the outer value is a JSON object. For production use, put complex variable values in a.tfvarsfile where HCL syntax is used instead of JSON.
Sensitive Variables
Some variables carry secrets — database passwords, API keys, private certificates. Terraform handles sensitive variables specially: they are redacted from all terminal output, plan files, and error messages. The value still exists in the state file — which is why state must be encrypted and access-controlled.
We are adding a sensitive database password variable. This variable has no default — it is required, must be supplied at runtime, and will never appear in any Terraform output.
New terms:
- sensitive = true — marks a variable as sensitive. Terraform redacts its value in all output — plan, apply, error messages, and
terraform output. It does not encrypt the value in the state file — state encryption is controlled by the backend configuration. Any output that references a sensitive variable is automatically marked sensitive too. - Required variable (no default) — a variable with no
defaultargument is required. If no value is supplied — via-var, environment variable, or tfvars file — Terraform will interactively prompt for it during plan or apply. In CI/CD, always supply required variables via environment variables to avoid interactive prompts hanging the pipeline. - TF_VAR_ environment variables — any environment variable prefixed with
TF_VAR_is automatically picked up by Terraform as a variable value.export TF_VAR_db_password="secret"supplies the value forvariable "db_password". This is the standard way to supply secrets in CI/CD pipelines without putting them in files.
Append this to variables.tf:
# ─── SENSITIVE VARIABLES ────────────────────────────────────────────────────
variable "db_password" {
description = "Master password for the RDS instance — never commit this value to Git"
type = string
sensitive = true # Redacts value from all Terraform output — plan, apply, errors
# No default — this variable is required and must be supplied at runtime
# Supply via: export TF_VAR_db_password="..." or -var="db_password=..."
validation {
# Enforce a minimum password length of 16 characters
condition = length(var.db_password) >= 16
error_message = "db_password must be at least 16 characters."
}
}
# Supply via environment variable — the correct approach in CI/CD
$ export TF_VAR_db_password="my-super-secret-password-here"
$ terraform plan
# aws_db_instance.main will be created
+ resource "aws_db_instance" "main" {
+ password = (sensitive value) # Never shown — redacted in all output
+ ...
}
# If you forget to supply it:
$ terraform plan
var.db_password
Master password for the RDS instance — never commit this value to Git
Enter a value: What just happened?
- The password appeared as (sensitive value) in the plan. Even though Terraform knows the value — it is using it to configure the RDS instance — it never prints it. This applies everywhere: plan output, apply output, error messages, and
terraform output. CI/CD logs that capture all terminal output never capture the secret. - TF_VAR_db_password was read automatically from the environment. The
TF_VAR_prefix is Terraform's standard mechanism for supplying variable values via the environment. No-varflag needed. Set it in your shell, your CI secret store, or your deployment pipeline — Terraform reads it transparently. - Without a value, Terraform prompted interactively. When a required variable has no value from any source, Terraform shows the description and an input prompt. In CI/CD where no terminal is attached, this causes the pipeline to hang. Always ensure all required variables have values before running plan or apply in automation.
The Variable Precedence Chain
When multiple sources supply a value for the same variable, Terraform uses a strict precedence order. The source with the highest precedence wins. Understanding this chain is essential when debugging unexpected variable values.
| Priority | Source | How to use it |
|---|---|---|
| 1 — Highest | -var flag on the command line | terraform apply -var="environment=prod" |
| 2 | -var-file flag on the command line | terraform apply -var-file="prod.tfvars" |
| 3 | *.auto.tfvars files (alphabetical order) | Any file ending in .auto.tfvars in the directory |
| 4 | terraform.tfvars file | Loaded automatically from the working directory |
| 5 | TF_VAR_ environment variables | export TF_VAR_region="eu-west-1" |
| 6 — Lowest | Default value in the variable declaration | default = "us-east-1" in variables.tf |
We are demonstrating the precedence chain by setting the same variable from three different sources simultaneously and observing which one wins.
# terraform.tfvars — auto-loaded, priority 4
environment = "staging"
# Run with environment set from three sources simultaneously
# TF_VAR_ = priority 5, terraform.tfvars = priority 4, -var flag = priority 1
export TF_VAR_environment="dev"
terraform plan \
-var="environment=prod" # Priority 1 — this wins
$ export TF_VAR_environment="dev"
$ terraform plan -var="environment=prod"
# Terraform evaluated all three sources:
# TF_VAR_environment = "dev" (priority 5)
# terraform.tfvars = "staging" (priority 4)
# -var flag = "prod" (priority 1)
#
# -var flag wins — environment = "prod"
+ resource "aws_instance" "web" {
+ tags = {
+ "Environment" = "prod" # confirms -var flag won
}
}What just happened?
- The -var flag at priority 1 overrode everything else. Even though
TF_VAR_environmentwas set in the shell andterraform.tfvarshad a value, the command-line flag won. This is by design — the most explicit, most specific source wins. Use this in CI/CD to override tfvars defaults for production deployments. - The precedence chain reads bottom to top — each level overrides the one below it. Think of defaults as the floor — always there unless something overrides them. tfvars files override defaults. Environment variables override tfvars. Command-line flags override everything. Understanding this order explains every confusing "why is my variable not taking effect" debugging session.
- Multiple -var-file flags are processed left to right, last one wins. If you run
terraform apply -var-file="base.tfvars" -var-file="prod.tfvars", both files are loaded and for any key that appears in both, the value fromprod.tfvarswins because it was processed last. This lets you compose variable files — a base with shared values and an environment-specific overlay.
Putting It All Together — main.tf
Every variable type and practice from this lesson applied to a real main.tf. This creates a security group with dynamic rules driven by the port list, EC2 instances driven by the instances map, and an RDS database driven by the object variable. Run this to see everything work together.
First add this to locals.tf:
locals {
# Consistent name prefix used across all resource names
name_prefix = "lesson11-${var.environment}"
# Common tags applied via merge() in every resource block
common_tags = {
Project = "terraform-course"
Environment = var.environment
ManagedBy = "Terraform"
}
}
Now add this to main.tf:
# Security group — ingress rules generated dynamically from var.ingress_ports list
resource "aws_security_group" "web" {
name = "${local.name_prefix}-web-sg"
description = "Web security group for ${var.environment}"
# dynamic block iterates var.ingress_ports and creates one ingress rule per port
# each.value is the current port number from the list
dynamic "ingress" {
for_each = var.ingress_ports
content {
description = "Allow port ${ingress.value}"
from_port = ingress.value # Port number from the list
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
egress {
description = "Allow all outbound"
from_port = 0
to_port = 0
protocol = "-1" # All protocols
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web-sg"
})
}
# EC2 instances — one per entry in var.instances map
# for_each uses the map key as the stable resource identifier
resource "aws_instance" "web" {
for_each = var.instances
ami = "ami-0c55b159cbfafe1f0" # Amazon Linux 2 — us-east-1
instance_type = each.value # Instance type from map value
vpc_security_group_ids = [aws_security_group.web.id]
# enable_detailed_monitoring comes from the bool variable
monitoring = var.enable_detailed_monitoring
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web-${each.key}" # e.g. lesson11-dev-web-primary
Role = each.key
})
lifecycle {
create_before_destroy = true # Zero-downtime replacements
ignore_changes = [ami] # AMI managed by external patching — do not revert
}
}
$ export TF_VAR_db_password="my-super-secure-password-123"
$ terraform plan
# aws_security_group.web will be created
+ resource "aws_security_group" "web" {
+ name = "lesson11-dev-web-sg"
+ ingress = [
+ { from_port = 80, to_port = 80, protocol = "tcp" },
+ { from_port = 443, to_port = 443, protocol = "tcp" },
]
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "Terraform"
+ "Name" = "lesson11-dev-web-sg"
+ "Project" = "terraform-course"
}
}
# aws_instance.web["primary"] will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c55b159cbfafe1f0"
+ instance_type = "t2.micro"
+ monitoring = false
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "Terraform"
+ "Name" = "lesson11-dev-web-primary"
+ "Project" = "terraform-course"
+ "Role" = "primary"
}
}
Plan: 2 to add, 0 to change, 0 to destroy.What just happened?
- The dynamic block generated two ingress rules from var.ingress_ports. The default list is
[80, 443]— two entries, two rules. Change the variable to[80, 443, 8080, 8443]and four rules appear. The resource block never changes — only the variable value does. - monitoring = false because enable_detailed_monitoring defaults to false. The bool variable drives the
monitoringargument on the EC2 instance directly. Setenable_detailed_monitoring = truein your prod tfvars file and CloudWatch detailed monitoring activates for every instance in thevar.instancesmap — automatically, on the next apply. - Every resource has four common tags without a single hardcoded tag in a resource block. The
merge(local.common_tags, {...})pattern applied Environment, ManagedBy, Project, and the resource-specific Name in every resource. Add a new tag tolocal.common_tagsinlocals.tfand it appears on every resource in the project on the next apply.
Common Mistakes
Assuming sensitive = true encrypts the state file
sensitive = true only controls what Terraform prints to the terminal. The value is still stored as plaintext in the state file. State file encryption is controlled by the backend — encrypt = true in the S3 backend configuration. Both are needed. One without the other is incomplete protection.
Using list when for_each needs a set or map
for_each does not accept a list directly — it requires a map or set. If you try for_each = var.ingress_ports where ingress_ports is a list(number), Terraform throws an error. Convert a list to a set first: for_each = toset(var.ingress_ports). Or redesign the variable as a map or set from the start.
Hardcoding secrets in variable defaults
A default value on a sensitive variable is still committed to Git in variables.tf. default = "my-password" on a sensitive variable defeats the entire purpose of marking it sensitive. Required sensitive variables — those with no default — force the caller to supply the value explicitly, which is the correct behaviour. Never put a real secret as a default.
When to use object vs separate variables
Group related settings that always appear together into an object — database configuration, network configuration, scaling policies. Keep independent settings as separate variables — region, environment, project name. The test: if removing one variable makes another variable meaningless, they belong in the same object. If they are independently useful, keep them separate.
Practice Questions
1. What prefix must an environment variable have for Terraform to automatically use it as a variable value?
2. When the same variable is set in terraform.tfvars, a TF_VAR_ environment variable, and a -var flag, which source wins?
3. Which keyword inside an object type declaration makes an attribute optional with a fallback default value?
Quiz
1. What exactly does sensitive = true on a variable do — and what does it not do?
2. You have a list(string) variable and want to use it with for_each. What must you do first?
3. How do you validate that a string variable matches a specific format pattern inside a validation block?
Up Next · Lesson 12
Output Values
Variables are inputs. Outputs are what your configuration gives back. Lesson 12 covers outputs as module return values, cross-stack references, and the patterns that let one Terraform configuration consume another's results.