Table of Contents
- Variables, Outputs & State
- 📦 Code Examples
- The Problem: Everything's Hardcoded
- The Solution: Variables, Outputs, State
- Input Variables: Stop Hardcoding Everything
- Validation: Catch Errors Before Deployment
- Variable Precedence: Where Values Come From
- The Precedence Chain (Lowest to Highest)
- Multi-Environment Setup
- Environment Variables
- Command-Line Variables
- Outputs: Stop Hunting for Information
- State Management: Terraform's Memory
- Sensitive Data: Don't Leak Your Passwords
- The Problem
- Solution 1: Mark Variables as Sensitive
- Solution 2: Encrypted Remote State
- Solution 3: External Secrets Management (Best Practice)
- Real-World Pattern: Multi-Environment Setup
- Quick Check: Do You Get It?
- What's Next?
- Resources
Variables, Outputs & State
You've written your first Terraform resources. The code works. But everything's hardcoded.
Want to deploy the same infrastructure to staging and production? Copy-paste everything and manually change values. Need the database endpoint for your app? SSH into the instance and hunt for it. Want to know what actually exists in your cloud account? Hope your state file is still around.
This doesn't scale.
In this part, you'll learn how to build flexible, reusable infrastructure with variables, outputs, and state management. Same codebase, multiple environments. No more hunting for IP addresses. No more "wait, did we deploy that already?"
📦 Code Examples
Repository: terraform-hcl-tutorial-series This Part: Part 5 - Variables and State
Get the working example:
git clone https://github.com/khuongdo/terraform-hcl-tutorial-series.git
cd terraform-hcl-tutorial-series
git checkout part-05
cd examples/part-05-variables-state/
# Explore variable patterns
terraform init
terraform plan -var-file="dev.tfvars"
The Problem: Everything's Hardcoded
Here's what beginner Terraform looks like:
# main.tf
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0" # What if we want a different AMI in staging?
instance_type = "t3.medium" # Production size for dev environment?
tags = {
Name = "prod-web-server" # Hardcoded "prod" in dev code?
Environment = "production"
Owner = "alice@company.com" # What if Bob is deploying this?
}
}
resource "aws_db_instance" "main" {
engine = "postgres"
instance_class = "db.t3.large" # Same size for dev and prod?
username = "admin"
password = "supersecret123" # Please tell me you didn't commit this
}
What's wrong here?
- Zero reusability (can't deploy to staging without duplicating everything)
- Environment-specific values baked into code
- Changing instance size requires code changes
- Passwords in Git (hope you like security incidents)
- No way to get the database endpoint after deployment
Real teams manage dozens of environments. This approach collapses immediately.
The Solution: Variables, Outputs, State
Three concepts fix this mess:
- Input Variables: Make your code flexible
- Outputs: Get important values after deployment
- State: Track what actually exists
Let's fix that hardcoded disaster.
Input Variables: Stop Hardcoding Everything
Variables turn your infrastructure into a template. Define once, deploy anywhere.
Basic Variables
# variables.tf
variable "instance_type" {
type = string
description = "EC2 instance type for web servers"
default = "t3.small"
}
variable "environment" {
type = string
description = "Deployment environment (dev, staging, prod)"
}
variable "enable_monitoring" {
type = bool
description = "Enable CloudWatch detailed monitoring"
default = false
}
Now use them:
# main.tf
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
monitoring = var.enable_monitoring
tags = {
Name = "${var.environment}-web-server"
Environment = var.environment
}
}
Same code. Different values per environment. That's the point.
Variable Types Beyond Strings
Terraform supports rich types for complex configurations:
# String
variable "region" {
type = string
default = "us-east-1"
}
# Number
variable "instance_count" {
type = number
default = 2
}
# Boolean
variable "enable_backups" {
type = bool
default = true
}
# List (ordered collection)
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
# Map (key-value pairs)
variable "instance_types" {
type = map(string)
default = {
dev = "t3.micro"
staging = "t3.small"
prod = "t3.large"
}
}
# Object (structured data)
variable "database_config" {
type = object({
engine = string
engine_version = string
instance_class = string
storage_gb = number
multi_az = bool
})
default = {
engine = "postgres"
engine_version = "15.4"
instance_class = "db.t3.small"
storage_gb = 20
multi_az = false
}
}
Use the right type for the job. Lists for ordered stuff, maps for lookups, objects for structured config.
Using Complex Variables
# Different instance types per environment
resource "aws_instance" "web" {
instance_type = var.instance_types[var.environment]
# dev -> t3.micro, prod -> t3.large
}
# Spread instances across availability zones
resource "aws_subnet" "public" {
count = length(var.availability_zones)
availability_zone = var.availability_zones[count.index]
}
# Use structured database configuration
resource "aws_db_instance" "main" {
engine = var.database_config.engine
engine_version = var.database_config.engine_version
instance_class = var.database_config.instance_class
allocated_storage = var.database_config.storage_gb
multi_az = var.database_config.multi_az
}
Validation: Catch Errors Before Deployment
Variables without validation are dangerous. Someone will set environment = "production123" by accident. Validation catches that before it hits your cloud account.
Basic Validation
variable "environment" {
type = string
description = "Deployment environment"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
Run terraform plan with environment = "production":
Error: Invalid value for variable
on variables.tf line 2:
2: variable "environment" {
Environment must be dev, staging, or prod.
Caught the typo before deploying. That's the point.
Advanced Validation
# Validate region format
variable "region" {
type = string
validation {
condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]$", var.region))
error_message = "Region must match format: us-east-1, eu-west-2, etc."
}
}
# Validate instance count range
variable "instance_count" {
type = number
validation {
condition = var.instance_count >= 1 && var.instance_count <= 10
error_message = "Instance count must be between 1 and 10."
}
}
# Validate CIDR block format
variable "vpc_cidr" {
type = string
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "Must be a valid IPv4 CIDR block."
}
}
# Cross-field validation
variable "database_config" {
type = object({
instance_class = string
multi_az = bool
storage_gb = number
})
validation {
condition = (
var.database_config.multi_az == false ||
var.database_config.storage_gb >= 100
)
error_message = "Multi-AZ databases require at least 100GB storage."
}
}
Validation is documentation. It tells users exactly what values are acceptable and why.
Variable Precedence: Where Values Come From
Terraform loads variable values from multiple sources. Understanding precedence prevents confusion.
The Precedence Chain (Lowest to Highest)
- Default values in
variableblocks - Environment variables (
TF_VAR_name) terraform.tfvarsfileterraform.tfvars.jsonfile*.auto.tfvarsfiles (alphabetical order)-var-file=...command-line flag-var=...command-line flag
Higher precedence overwrites lower. CLI flags always win.
Multi-Environment Setup
# Directory structure
terraform/
├── main.tf
├── variables.tf
├── terraform.tfvars # Default values
├── environments/
│ ├── dev.tfvars # Development overrides
│ ├── staging.tfvars # Staging overrides
│ └── prod.tfvars # Production overrides
terraform.tfvars (defaults):
region = "us-east-1"
enable_monitoring = false
backup_retention_days = 7
environments/prod.tfvars (production overrides):
instance_type = "t3.large"
enable_monitoring = true
backup_retention_days = 30
multi_az = true
Deploy to production:
terraform apply -var-file="environments/prod.tfvars"
Same codebase, different configurations. Netflix uses this pattern to deploy to 190+ countries.
Environment Variables
# Set variables via environment (useful in CI/CD)
export TF_VAR_region="eu-west-1"
export TF_VAR_instance_count=5
terraform apply
# No need to pass -var flags
Command-Line Variables
# Override individual values
terraform apply \
-var="environment=staging" \
-var="instance_count=3"
Use environment variables in CI/CD pipelines. Use .tfvars files for persistent configuration.
Outputs: Stop Hunting for Information
After deploying infrastructure, you need information: database endpoints, load balancer URLs, IP addresses. Outputs solve this.
Basic Outputs
# outputs.tf
output "instance_public_ip" {
description = "Public IP address of web server"
value = aws_instance.web.public_ip
}
output "database_endpoint" {
description = "RDS database connection endpoint"
value = aws_db_instance.main.endpoint
}
output "load_balancer_url" {
description = "Application load balancer URL"
value = "https://${aws_lb.main.dns_name}"
}
After terraform apply:
Outputs:
instance_public_ip = "54.123.45.67"
database_endpoint = "mydb.abc123.us-east-1.rds.amazonaws.com:5432"
load_balancer_url = "https://my-alb-123456.us-east-1.elb.amazonaws.com"
Copy-paste ready. No hunting through AWS Console.
Sensitive Outputs
output "database_password" {
description = "Master password for RDS instance"
value = aws_db_instance.main.password
sensitive = true
}
With sensitive = true:
terraform apply
Outputs:
database_password = <sensitive>
Password not leaked in logs. View it explicitly:
terraform output database_password
# Shows actual password
Always mark secrets as sensitive. Prevents accidental exposure in CI/CD logs.
Structured Outputs
# Group related outputs
output "database_connection" {
description = "Complete database connection information"
value = {
endpoint = aws_db_instance.main.endpoint
port = aws_db_instance.main.port
database = aws_db_instance.main.db_name
username = aws_db_instance.main.username
}
sensitive = true
}
Access in scripts:
terraform output -json database_connection | jq '.endpoint'
Outputs for Module Composition
When building modules (Part 7), outputs become module interfaces:
# networking module outputs
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
Used by application module:
module "networking" {
source = "./modules/networking"
}
module "application" {
source = "./modules/application"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.public_subnet_ids
}
Outputs connect modules together. They're the API between infrastructure layers.
State Management: Terraform's Memory
Terraform tracks your infrastructure in a state file. This is how it knows what exists, what changed, and what to update.
What Is State?
When you run terraform apply, Terraform:
- Reads the desired state from your
.tffiles - Reads the current state from
terraform.tfstate - Compares them to generate a plan
- Executes changes to make current = desired
The state file is Terraform's memory. Lose it, and Terraform forgets your infrastructure exists.
State File Anatomy
After deploying an EC2 instance, terraform.tfstate contains:
{
"version": 4,
"terraform_version": "1.7.0",
"serial": 12,
"lineage": "a1b2c3d4-e5f6-7890-abcd-1234567890ab",
"outputs": {
"instance_ip": {
"value": "54.123.45.67",
"type": "string"
}
},
"resources": [
{
"mode": "managed",
"type": "aws_instance",
"name": "web",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"id": "i-0abcd1234efgh5678",
"ami": "ami-0c55b159cbfafe1f0",
"instance_type": "t3.medium",
"public_ip": "54.123.45.67",
"private_ip": "10.0.1.42"
}
}
]
}
]
}
Key elements:
- version: State file format version
- serial: Increments with each change (detects concurrent modifications)
- lineage: Unique ID preventing state file mixups
- outputs: Cached output values
- resources: Complete resource attributes (IDs, IPs, ARNs)
Local vs Remote State
Local state (default):
# State stored in terraform.tfstate in current directory
terraform apply
# Creates/updates ./terraform.tfstate
Problems with local state:
- No collaboration (team members can't share state)
- No locking (concurrent
terraform applycauses corruption) - No backup (delete the file = lose infrastructure tracking)
- Secrets in plaintext (state contains passwords, keys)
Remote state (production approach):
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks" # State locking
}
}
Benefits:
- Shared access (team uses same state)
- Locking (DynamoDB prevents concurrent modifications)
- Encryption (state encrypted at rest)
- Versioning (S3 versioning enables rollback)
We'll cover remote state backends in Part 9. For now: never use local state in production.
State Locking
Imagine two engineers run terraform apply simultaneously:
- Both read current state: "2 instances exist"
- Both plan to create 1 more instance
- Both apply changes
- Result: 4 instances instead of 3 (race condition)
State locking prevents this:
# Engineer 1
terraform apply
Acquiring state lock. This may take a few moments...
Lock acquired (ID: abc-123)
# Engineer 2 (simultaneously)
terraform apply
Acquiring state lock. This may take a few moments...
Error: Error locking state: ConditionalCheckFailedException
Lock Info:
ID: abc-123
Operation: OperationTypeApply
Who: engineer1@laptop
Created: 2026-03-12 14:32:15 UTC
Engineer 2's apply blocked until Engineer 1 finishes. No race conditions.
Sensitive Data: Don't Leak Your Passwords
Terraform state contains everything about your infrastructure, including secrets. Handle carefully.
The Problem
resource "aws_db_instance" "main" {
password = "supersecret123" # DON'T DO THIS
}
After terraform apply, terraform.tfstate contains:
{
"resources": [{
"instances": [{
"attributes": {
"password": "supersecret123" # Plaintext in state
}
}]
}]
}
Anyone with state file access has your database password.
Solution 1: Mark Variables as Sensitive
variable "db_password" {
type = string
sensitive = true
}
Terraform hides sensitive values in logs:
terraform plan
# aws_db_instance.main will be created
+ resource "aws_db_instance" "main" {
+ password = (sensitive value)
}
But state still contains plaintext. This only prevents log leakage.
Solution 2: Encrypted Remote State
terraform {
backend "s3" {
bucket = "my-state-bucket"
key = "terraform.tfstate"
encrypt = true # Encrypts state at rest
# KMS encryption for extra security
kms_key_id = "arn:aws:kms:us-east-1:123456789:key/abc-123"
}
}
State encrypted in S3. Requires AWS KMS permissions to read.
Solution 3: External Secrets Management (Best Practice)
Never store secrets in Terraform. Fetch them at runtime:
# Fetch password from AWS Secrets Manager
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "prod/database/master-password"
}
resource "aws_db_instance" "main" {
password = data.aws_secretsmanager_secret_version.db_password.secret_string
}
Secret stored in Secrets Manager, not Git. State still contains it, but encrypted at rest.
We'll cover HashiCorp Vault integration in Part 11. For now: secrets belong in secret stores, not code.
Real-World Pattern: Multi-Environment Setup
Let's put it all together:
terraform/
├── main.tf # Core infrastructure
├── variables.tf # Variable declarations
├── outputs.tf # Output definitions
├── backend.tf # Remote state configuration
├── terraform.tfvars # Default values
└── environments/
├── dev.tfvars # Development overrides
├── staging.tfvars # Staging overrides
└── prod.tfvars # Production overrides
variables.tf:
variable "environment" {
type = string
description = "Deployment environment"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Must be dev, staging, or prod."
}
}
variable "instance_config" {
type = object({
type = string
count = number
})
description = "EC2 instance configuration"
validation {
condition = var.instance_config.count >= 1 && var.instance_config.count <= 20
error_message = "Instance count must be 1-20."
}
}
variable "enable_multi_az" {
type = bool
description = "Enable multi-AZ deployment for high availability"
default = false
}
main.tf:
resource "aws_instance" "web" {
count = var.instance_config.count
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_config.type
tags = {
Name = "${var.environment}-web-${count.index + 1}"
Environment = var.environment
}
}
resource "aws_db_instance" "main" {
engine = "postgres"
instance_class = var.environment == "prod" ? "db.t3.large" : "db.t3.small"
multi_az = var.enable_multi_az
# Password from Secrets Manager
password = data.aws_secretsmanager_secret_version.db_password.secret_string
}
outputs.tf:
output "web_server_ips" {
description = "Public IPs of web servers"
value = aws_instance.web[*].public_ip
}
output "database_endpoint" {
description = "Database connection endpoint"
value = aws_db_instance.main.endpoint
sensitive = true
}
environments/prod.tfvars:
environment = "prod"
instance_config = {
type = "t3.large"
count = 5
}
enable_multi_az = true
Deploy to production:
terraform apply -var-file="environments/prod.tfvars"
Plan: 6 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
Outputs:
web_server_ips = [
"54.123.45.67",
"54.123.45.68",
"54.123.45.69",
"54.123.45.70",
"54.123.45.71"
]
database_endpoint = <sensitive>
Same code for dev, staging, prod. Only variables change.
Quick Check: Do You Get It?
Before moving to Part 6, can you answer these?
What's the difference between
list(string)andmap(string)?- Lists are ordered, accessed by index (
[0],[1]). Maps are key-value pairs, accessed by key (["dev"],["prod"]).
- Lists are ordered, accessed by index (
Which wins:
terraform.tfvarsor-varCLI flag?- CLI flags have highest precedence.
-varoverrides everything.
- CLI flags have highest precedence.
Why mark outputs as
sensitive = true?- Prevents secrets from appearing in console output and CI/CD logs. Forces explicit
terraform output <name>to view.
- Prevents secrets from appearing in console output and CI/CD logs. Forces explicit
What happens if you lose the state file?
- Terraform forgets your infrastructure exists. Next
applytries to recreate everything, causing conflicts.
- Terraform forgets your infrastructure exists. Next
Where should production secrets be stored?
- External secret stores (AWS Secrets Manager, HashiCorp Vault). Reference them via
datasources at runtime.
- External secret stores (AWS Secrets Manager, HashiCorp Vault). Reference them via
If you're confident in these answers, you're ready for Part 6.
What's Next?
In Part 6: Core Terraform Workflow, you'll master the development cycle:
terraform init: Initialize providers and modulesterraform plan: Preview changes before applyingterraform apply: Deploy infrastructure safelyterraform destroy: Tear down resources cleanlyterraform refresh: Sync state with realityterraform taint: Force resource recreation
Plus workflows for handling drift, recovering from failures, and debugging plans.
Ready to learn the Terraform development loop? Continue to Part 6 → (coming soon)
Resources
- Terraform Variables Documentation
- Terraform Outputs Documentation
- Terraform State Documentation
- AWS Secrets Manager Terraform Provider
Series navigation:
- Part 1: Why Infrastructure as Code?
- Part 2: Setting Up Terraform
- Part 3: Your First Cloud Resource
- Part 4: HCL Fundamentals
- Part 5: Variables, Outputs & State (You are here)
- Part 6: Core Terraform Workflow
- Part 7: Modules for Organization (Coming soon)
- Part 8: Multi-Cloud Patterns (Coming soon)
- Part 9: State Management & Team Workflows (Coming soon)
- Part 10: Testing & Validation (Coming soon)
- Part 11: Security & Secrets Management (Coming soon)
- Part 12: Production Patterns & DevSecOps (Coming soon)
This post is part of the "Terraform from Fundamentals to Production" series.