Overview and What You Will Learn
A Terraform configuration that hardcodes every value — region, instance type, bucket name, environment tag — is a configuration you can only use once. The moment you need a second environment, you copy the entire folder, change twenty values, and now maintain two near-identical codebases. When a security group rule needs updating, you change it in two places. Miss one, and your environments drift.
Terraform variables are the solution. They turn your configuration into a template — write the infrastructure once, fill in the blanks differently for dev, staging, and production. This is the exact pattern Zerodha uses to run the same Terraform code across three environments without duplication.
In this lab you will master every HCL building block that makes configurations reusable and maintainable:
- Input variables — parameters that callers pass in, with types, defaults, and validation
- Output values — infrastructure attributes exposed after apply for humans and other configs to read
- Local values — intermediate calculations you write once and reference many times
- Expressions — conditionals, for loops, and built-in functions that add logic to your config
- Dynamic blocks — generating repeated nested blocks programmatically from a list
By the end, you will refactor a hardcoded configuration into a fully parameterised one that works across any environment with a single variable file.
Why This Matters in Production
At Zerodha, one Terraform configuration manages infrastructure for dev, staging, and production. The resources are identical in structure — same VPC layout, same security group rules, same RDS schema. Only the values differ: t3.micro in dev, t3.large in prod; 20 GB storage in dev, 500 GB in prod; deletion protection off in dev, on in prod.
Variables make this possible. When a new port needs to be opened in the security group, an engineer changes one variable definition. Terraform applies it to all three environments through a CI/CD pipeline. No copy-paste. No missed environments. No drift.
Without variables, the team would maintain three copies of the same configuration — and every change would need to be made in triplicate, with a high chance of divergence.
Core Principles
+------------------------------------------+| Value Sources (where values come from) || terraform.tfvars, TF_VAR_, -var flag |+------------------------------------------+ | v+------------------------------------------+| variables.tf (input declarations) || variable "environment" { type = string } || variable "instance_type" { type = string}|+------------------------------------------+ | v+------------------------------------------+| locals block (computed expressions) || name_prefix = "zerodha-${var.env}" || is_prod = var.environment == "prod" |+------------------------------------------+ | v+------------------------------------------+| main.tf (resources use var.x / local.x) || bucket = local.name_prefix || instance_type = local.instance_size |+------------------------------------------+ | v+------------------------------------------+| outputs.tf (expose attributes after apply|| output "bucket_name" { value = ... } || output "instance_ip" { value = ... } |+------------------------------------------+Detailed Step-by-Step Practical Lab
This lab refactors a hardcoded configuration into a fully parameterised one. Start with this rigid configuration and improve it step by step.
Starting Point — The Hardcoded Configuration (Do Not Copy This Into Production)
# main.tf — BAD: everything hardcoded, not reusableresource "aws_s3_bucket" "data" { bucket = "zerodha-data-dev" # hardcoded environment name tags = { Environment = "dev" # hardcoded tag ManagedBy = "terraform" }} resource "aws_instance" "app" { ami = "ami-0f5ee92e2d63afc18" instance_type = "t3.micro" # hardcoded — different for prod tags = { Name = "zerodha-app-dev" # hardcoded Environment = "dev" }}This config cannot be reused. Every environment change means editing the file directly. By the end of this lab, a single terraform apply -var-file=prod.tfvars will deploy the same code to production with the correct values.
Part 1 — Input Variables
Input variables are the parameters of your configuration. They separate the what (resource structure) from the how (specific values). Declare every variable in variables.tf.
Primitive Types
# variables.tf # String — the most common typevariable "environment" { description = "Deployment environment — controls resource sizing and naming" type = string default = "dev"} # Number — for counts, sizes, portsvariable "replica_count" { description = "Number of EC2 instances to run behind the load balancer" type = number default = 2} # Bool — for feature flagsvariable "enable_deletion_protection" { description = "Prevent terraform destroy from deleting the RDS instance" type = bool default = false # set to true in prod — see prod.tfvars}Collection Types
# variables.tf (continued) # list(string) — ordered collection, allows duplicatesvariable "availability_zones" { description = "AZs to spread subnets across for high availability" type = list(string) default = ["ap-south-1a", "ap-south-1b"]} # map(string) — key-value pairs, all values same typevariable "instance_types" { description = "EC2 instance type to use in each environment" type = map(string) default = { dev = "t3.micro" # cheapest — fine for development staging = "t3.small" # more headroom for load testing prod = "t3.large" # production traffic load }} # set(string) — unordered collection, no duplicatesvariable "allowed_cidrs" { description = "IP ranges allowed to reach the application" type = set(string) default = ["10.0.0.0/8", "172.16.0.0/12"]}Structural Types
# variables.tf (continued) # object — structured data with named attributes of different typesvariable "database_config" { description = "RDS configuration — engine, version, sizing, and backup" type = object({ engine = string engine_version = string instance_class = string allocated_storage_gb = number multi_az = bool backup_retention_days = number }) default = { engine = "postgres" engine_version = "14.9" instance_class = "db.t3.medium" allocated_storage_gb = 20 multi_az = false # true in prod backup_retention_days = 7 }} # list(object) — list of structured items (e.g., security group rules)variable "ingress_rules" { description = "Inbound traffic rules for the application security group" type = list(object({ port = number protocol = string description = string cidr_blocks = list(string) })) default = [ { port = 443 protocol = "tcp" description = "HTTPS from internet" cidr_blocks = ["0.0.0.0/0"] }, { port = 8080 protocol = "tcp" description = "Internal health checks from ALB" cidr_blocks = ["10.0.0.0/8"] } ]}Validation — Catching Mistakes Before Plan
# variables.tf (continued) variable "aws_region" { description = "AWS region for all resources — must be Mumbai or Virginia" type = string default = "ap-south-1" # Validation runs at plan time before any API calls # If the condition is false, error_message is shown and plan stops validation { condition = contains(["ap-south-1", "us-east-1"], var.aws_region) error_message = "Region must be ap-south-1 (Mumbai) or us-east-1 (N. Virginia)." }} variable "project_name" { description = "Project slug — used as prefix in all resource names and tags" type = string validation { # Only lowercase letters, numbers, and hyphens — no spaces or underscores condition = can(regex("^[a-z0-9-]+$", var.project_name)) error_message = "Project name must be lowercase letters, numbers, and hyphens only." } validation { # Must not exceed 20 characters — S3 bucket names have a 63-char limit # and we append suffixes, so keep the prefix short condition = length(var.project_name) <= 20 error_message = "Project name must be 20 characters or fewer." }} # Sensitive variable — value is hidden in plan output and terminal logsvariable "db_master_password" { description = "Master password for the RDS instance — never log this" type = string sensitive = true # plan output shows (sensitive) instead of the value # WARNING: still stored in plaintext in state file # Use Vault or Secrets Manager for true secret management}Part 2 — Passing Variable Values
Variables can be set in four ways. Terraform applies them in priority order — highest wins.
# ── Method 1: terraform.tfvars (auto-loaded, lowest priority) ─────────────# Terraform automatically loads this file if it exists in the working directorycat > terraform.tfvars << 'EOF'project_name = "zerodha-api"environment = "dev"aws_region = "ap-south-1"# Do NOT put db_master_password here — this file goes in GitEOF # ── Method 2: Named .tfvars file (explicit, per environment) ──────────────cat > dev.tfvars << 'EOF'environment = "dev"replica_count = 1enable_deletion_protection = falsedatabase_config = { engine = "postgres" engine_version = "14.9" instance_class = "db.t3.micro" allocated_storage_gb = 20 multi_az = false backup_retention_days = 3}EOF cat > prod.tfvars << 'EOF'environment = "prod"replica_count = 3enable_deletion_protection = truedatabase_config = { engine = "postgres" engine_version = "14.9" instance_class = "db.r6g.large" allocated_storage_gb = 500 multi_az = true backup_retention_days = 30}EOF # Apply using environment-specific variable fileterraform plan -var-file=prod.tfvarsterraform apply -var-file=prod.tfvars # ── Method 3: Environment variables (for secrets and CI/CD) ───────────────# Prefix any variable name with TF_VAR_ and Terraform reads it automaticallyexport TF_VAR_db_master_password="V3ryS3cur3P@ssw0rd"export TF_VAR_environment="prod" # ── Method 4: -var flag (highest priority, overrides everything) ──────────terraform plan -var="environment=staging" -var="replica_count=2"Variable Precedence — Highest to Lowest
+------------------------------------------+| -var and -var-file flags | <- highest: overrides everything+------------------------------------------+ |+------------------------------------------+| TF_VAR_ environment variables |+------------------------------------------+ |+------------------------------------------+| *.auto.tfvars (alphabetical order) | <- auto-loaded+------------------------------------------+ |+------------------------------------------+| terraform.tfvars | <- auto-loaded+------------------------------------------+ |+------------------------------------------+| default values in variable {} blocks | <- lowest: used if nothing else set+------------------------------------------+Part 3 — Output Values
Outputs expose specific infrastructure attributes after apply. They serve two purposes: printing useful information for engineers reading the terminal, and making values available for other Terraform configurations to read via remote state.
# outputs.tf # Basic output — string valueoutput "s3_bucket_name" { description = "Name of the S3 data bucket — used in application config" value = aws_s3_bucket.data.id # .id is the bucket name for S3} # ARN output — needed for IAM policy documentsoutput "s3_bucket_arn" { description = "ARN of the S3 data bucket — use in IAM policy Resource fields" value = aws_s3_bucket.data.arn} # Sensitive output — value hidden in terminal, accessible via terraform output -rawoutput "db_connection_string" { description = "PostgreSQL connection string for the application" value = "postgres://app:${var.db_master_password}@${aws_db_instance.main.endpoint}/payments" sensitive = true # hidden in terminal output — use terraform output -raw to read} # List output — multiple values from for_eachoutput "instance_ids" { description = "EC2 instance IDs for all replicas — use in load balancer target group" value = [for instance in aws_instance.app : instance.id]} # Map output — structured data for multiple related valuesoutput "database_connection" { description = "Database connection parameters for the application" value = { host = aws_db_instance.main.address port = aws_db_instance.main.port name = aws_db_instance.main.db_name endpoint = aws_db_instance.main.endpoint }}# After terraform apply — read outputsterraform output # print all outputsterraform output s3_bucket_name # print one specific outputterraform output -raw s3_bucket_name # raw value without quotes (use in scripts)terraform output -json # all outputs as machine-readable JSON # Read a sensitive output (requires explicit -raw flag)terraform output -raw db_connection_string # In a shell script — capture an output into a variableBUCKET_NAME=$(terraform output -raw s3_bucket_name)echo "Deploying to bucket: $BUCKET_NAME"Part 4 — Local Values
A local value is a named expression you compute once and reference many times. Locals are not input parameters — callers cannot set them. They are internal to your configuration.
Use locals when:
- You write the same expression (
"${var.project}-${var.environment}") in more than one place - An expression is complex and naming it makes the intent clear
- You want to build a common_tags map once and attach it to every resource
# locals.tf — or add a locals block in main.tf locals { # ── Naming ─────────────────────────────────────────────────────────────── # Single source of truth for the naming prefix # Every resource name starts with this — zerodha-api-prod name_prefix = "${var.project_name}-${var.environment}" # ── Tagging ────────────────────────────────────────────────────────────── # Apply these tags to every resource via tags = local.common_tags # Saves writing the same tags block on every resource block common_tags = { Project = var.project_name Environment = var.environment ManagedBy = "terraform" Repository = "github.com/zerodha/infrastructure" CostCenter = "platform-engineering" } # ── Conditional sizing ──────────────────────────────────────────────────── # Look up instance type from the map variable using current environment as key # Fallback to t3.micro if environment key not found in the map ec2_instance_type = lookup(var.instance_types, var.environment, "t3.micro") # Boolean flag for production-specific behaviour is_production = var.environment == "prod" # Enable multi-AZ for prod, single-AZ for everything else rds_multi_az = local.is_production # ── Computed names ──────────────────────────────────────────────────────── # These reference local.name_prefix — locals can reference each other s3_bucket_name = "${local.name_prefix}-data" # zerodha-api-prod-data rds_identifier = "${local.name_prefix}-postgres" # zerodha-api-prod-postgres sg_name = "${local.name_prefix}-app-sg" # zerodha-api-prod-app-sg log_group_name = "/aws/${local.name_prefix}/app" # /aws/zerodha-api-prod/app # ── Account-scoped uniqueness ───────────────────────────────────────────── # S3 bucket names must be globally unique — include account ID to guarantee it unique_bucket_name = "${local.name_prefix}-data-${data.aws_caller_identity.current.account_id}"}# main.tf — using localsresource "aws_s3_bucket" "data" { bucket = local.unique_bucket_name # not var.project_name repeated everywhere tags = local.common_tags # one line instead of six repeated tag blocks} resource "aws_db_instance" "main" { identifier = local.rds_identifier multi_az = local.rds_multi_az # true in prod, false elsewhere instance_class = var.database_config.instance_class tags = local.common_tags}Part 5 — Expressions and Built-In Functions
locals { # ── Conditional (ternary) ───────────────────────────────────────────────── # condition ? value_if_true : value_if_false backup_retention = var.environment == "prod" ? 30 : 3 log_level = local.is_production ? "warn" : "debug" # ── String functions ─────────────────────────────────────────────────────── upper_env = upper(var.environment) # "PROD" lower_proj = lower(var.project_name) # "zerodha-api" env_initial = substr(var.environment, 0, 1) # "p" (first character) full_name = join("-", [var.project_name, var.environment]) # "zerodha-api-prod" parts = split("-", "zerodha-api-prod") # ["zerodha", "api", "prod"] trimmed = trimspace(" prod ") # "prod" replaced = replace(var.project_name, "-", "_") # "zerodha_api" (underscore) # ── List functions ───────────────────────────────────────────────────────── az_count = length(var.availability_zones) # 2 first_az = element(var.availability_zones, 0) # "ap-south-1a" sorted_azs = sort(var.availability_zones) # alphabetical unique_azs = distinct(["ap-south-1a", "ap-south-1a", "ap-south-1b"]) # dedup # Flatten a list of lists into a single list all_cidrs = flatten([ ["10.0.0.0/8", "172.16.0.0/12"], var.extra_cidrs, # might be empty list — flatten handles it ]) # Coerce a list to a set (removes duplicates, loses order) az_set = toset(var.availability_zones) # ── Map functions ────────────────────────────────────────────────────────── # Merge two maps — right side wins on key conflicts full_tags = merge( local.common_tags, { Name = local.name_prefix, Component = "api" } ) tag_keys = keys(local.common_tags) # ["CostCenter", "Environment", ...] tag_values = values(local.common_tags) # ["platform-engineering", "prod", ...] # Lookup — read a map value with a fallback default instance_size = lookup(var.instance_types, var.environment, "t3.micro") # ── Number functions ─────────────────────────────────────────────────────── storage_with_buffer = ceil(var.database_config.allocated_storage_gb * 1.2) # 20% headroom min_replicas = max(var.replica_count, 2) # never fewer than 2 # ── Type conversion ──────────────────────────────────────────────────────── # Convert string "true" to bool — useful when reading from env vars feature_enabled = tobool("true")}Part 6 — For Expressions
For expressions transform lists and maps. They are like list comprehensions in Python or Array.map in JavaScript — but for HCL.
locals { # ── List → List (transform) ─────────────────────────────────────────────── # Uppercase every environment name upper_envs = [for env in ["dev", "staging", "prod"] : upper(env)] # Result: ["DEV", "STAGING", "PROD"] # Prepend project name to every AZ az_labels = [for az in var.availability_zones : "${var.project_name}-${az}"] # Result: ["zerodha-api-ap-south-1a", "zerodha-api-ap-south-1b"] # ── List → List (filter) ────────────────────────────────────────────────── # Only non-dev environments non_dev_envs = [for env in ["dev", "staging", "prod"] : env if env != "dev"] # Result: ["staging", "prod"] # ── List → Map (index-keyed) ────────────────────────────────────────────── # Convert AZ list to map for use with for_each # for_each requires a map or set — lists do not work directly az_index_map = { for idx, az in var.availability_zones : az => idx } # Result: { "ap-south-1a" = 0, "ap-south-1b" = 1 } # ── Map → Map (transform values) ───────────────────────────────────────── # Add environment suffix to every instance type value tagged_instances = { for env, itype in var.instance_types : env => "${itype}-${env}" } # Result: { "dev" = "t3.micro-dev", "prod" = "t3.large-prod" } # ── Map → List (extract values) ─────────────────────────────────────────── instance_type_list = [for env, itype in var.instance_types : itype] # Result: ["t3.micro", "t3.small", "t3.large"]} # for_each using a for expression — create one subnet per AZresource "aws_subnet" "private" { # Convert list to AZ→index map so each subnet has a stable key for_each = { for idx, az in var.availability_zones : az => idx } vpc_id = aws_vpc.main.id availability_zone = each.key # "ap-south-1a" # cidrsubnet(base, newbits, netnum) carves a /24 from a /16 cidr_block = cidrsubnet(var.vpc_cidr, 8, each.value + 10) tags = merge(local.common_tags, { Name = "${local.name_prefix}-private-${each.key}" Tier = "private" })}Part 7 — Dynamic Blocks
Dynamic blocks generate repeated nested blocks from a list or map variable. Without them, you would write one ingress block per port — hardcoded, inflexible.
# variables.tfvariable "sg_ingress_rules" { description = "Security group inbound rules — add ports without touching resource blocks" type = list(object({ description = string from_port = number to_port = number protocol = string cidr_blocks = list(string) })) default = [ { description = "HTTPS from internet" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }, { description = "Internal API from VPC" from_port = 8080 to_port = 8080 protocol = "tcp" cidr_blocks = ["10.0.0.0/8"] }, { description = "Prometheus scrape from monitoring subnet" from_port = 9090 to_port = 9090 protocol = "tcp" cidr_blocks = ["10.10.0.0/24"] }, ]} # main.tfresource "aws_security_group" "app" { name = local.sg_name description = "Application security group for ${local.name_prefix}" # Dynamic block generates one ingress {} block per item in var.sg_ingress_rules # Add a new port by adding an item to the variable — no resource block changes dynamic "ingress" { for_each = var.sg_ingress_rules # iterator label defaults to block name ("ingress") content { description = ingress.value.description from_port = ingress.value.from_port to_port = ingress.value.to_port protocol = ingress.value.protocol cidr_blocks = ingress.value.cidr_blocks } } # Static egress — always allow all outbound egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = local.common_tags}Part 8 — The Refactored Configuration
Here is the complete refactored main.tf using all the concepts above — the hardcoded version from the start of the lab, now fully parameterised:
# main.tf — refactored: parameterised, reusable, production-ready data "aws_caller_identity" "current" {} # read AWS account ID locals { name_prefix = "${var.project_name}-${var.environment}" unique_bucket_name = "${local.name_prefix}-data-${data.aws_caller_identity.current.account_id}" ec2_instance_type = lookup(var.instance_types, var.environment, "t3.micro") common_tags = { Project = var.project_name Environment = var.environment ManagedBy = "terraform" }} resource "aws_s3_bucket" "data" { bucket = local.unique_bucket_name tags = local.common_tags} resource "aws_s3_bucket_versioning" "data" { bucket = aws_s3_bucket.data.id versioning_configuration { status = var.environment == "prod" ? "Enabled" : "Suspended" }} resource "aws_security_group" "app" { name = "${local.name_prefix}-app-sg" tags = local.common_tags dynamic "ingress" { for_each = var.sg_ingress_rules content { description = ingress.value.description from_port = ingress.value.from_port to_port = ingress.value.to_port protocol = ingress.value.protocol cidr_blocks = ingress.value.cidr_blocks } } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }} resource "aws_instance" "app" { count = var.replica_count # 1 in dev, 3 in prod ami = data.aws_ami.amazon_linux.id instance_type = local.ec2_instance_type # from map variable vpc_security_group_ids = [aws_security_group.app.id] tags = merge(local.common_tags, { Name = "${local.name_prefix}-app-${count.index + 1}" Index = tostring(count.index) })}Apply for dev environment:
terraform apply -var-file=dev.tfvarsApply the identical code for prod — only values change:
terraform apply -var-file=prod.tfvarsProduction Best Practices and Common Pitfalls
Write a description for every variable without exception. Engineers reading a
prod.tfvarsfile six months later have no other documentation. The description field is the inline docs.Always add validation blocks to variables with restricted values. An invalid environment name caught at
terraform plantime (before any API calls) saves debugging a confusing apply failure. Usecontains()for enums,regex()for format checks,length()for size limits.Never put secrets in
.tfvarsfiles that go into Git. UseTF_VAR_environment variables for passwords and API keys. In CI/CD, pull secrets from Vault or AWS Secrets Manager and export them asTF_VAR_before running Terraform.Use
sensitive = truefor secret variables and outputs. This hides values from terminal output and plan logs. It does NOT encrypt them in the state file — that requires a properly secured remote backend.Use locals instead of repeating the same expression. The pattern
"${var.project}-${var.environment}"written in eight resource blocks is eight places to update when the naming convention changes. Write it once in locals.Use
for_eachwith maps instead ofcountwith lists. If you usecount = 3to create three S3 buckets and later remove the middle one from the list, Terraform renumbers everything and may destroy and recreate buckets you did not intend to touch.for_eachwith a map gives each instance a stable key.Commit per-environment
.tfvarsfiles to Git. Files likedev.tfvars,staging.tfvars, andprod.tfvarsare configuration, not secrets. Having them in Git gives you a clear audit trail of what values each environment uses and when they changed.Use
terraform consoleto test expressions before putting them in config. The console gives you an interactive HCL evaluator — typeupper("hello")orcidrsubnet("10.0.0.0/16", 8, 3)to test your expression before adding it to a resource argument.
Quick Reference and Troubleshooting Commands
| Command | What It Does |
|---|---|
terraform output |
Print all output values after apply |
terraform output -json |
Print all outputs as machine-readable JSON |
terraform output -raw <name> |
Print one output value without quotes (for shell scripts) |
terraform console |
Open interactive HCL expression evaluator |
terraform validate |
Check configuration syntax without making API calls |
terraform plan -var-file=prod.tfvars |
Plan using a specific variable file |
terraform apply -var="env=prod" |
Override one variable on the command line |
TF_VAR_db_password=secret terraform apply |
Pass sensitive variable via environment |
| Error | Root Cause | Fix |
|---|---|---|
Error: Variables not allowed |
Using var.x inside a backend block |
Backend blocks cannot use variables — use partial config or hardcode |
Error: Invalid value for variable |
A validation condition returned false | Read the error_message in the validation block — fix the value |
Error: Unsupported attribute |
Wrong key name in an object variable | Check the object type definition in variables.tf |
Error: Cannot use sensitive value here |
Sensitive var used in resource count or for_each | Use a non-sensitive variable or use nonsensitive() carefully |
Output shows (sensitive value) |
Output or variable has sensitive = true |
Use terraform output -raw <name> to read the actual value |
Error: This object does not have an attribute named "x" |
Typo in object variable key | Check spelling against the object type definition |
terraform.tfvars not loading |
File not in working directory or misspelled | Run ls to confirm file exists; check exact spelling |
| Variable default not respected | Higher-priority source (env var or -var) overrides it | Check for TF_VAR_ env vars: `env |