Posted on :: 2478 Words :: Tags: , ,

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?

  1. Zero reusability (can't deploy to staging without duplicating everything)
  2. Environment-specific values baked into code
  3. Changing instance size requires code changes
  4. Passwords in Git (hope you like security incidents)
  5. 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:

  1. Input Variables: Make your code flexible
  2. Outputs: Get important values after deployment
  3. 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)

  1. Default values in variable blocks
  2. Environment variables (TF_VAR_name)
  3. terraform.tfvars file
  4. terraform.tfvars.json file
  5. *.auto.tfvars files (alphabetical order)
  6. -var-file=... command-line flag
  7. -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:

  1. Reads the desired state from your .tf files
  2. Reads the current state from terraform.tfstate
  3. Compares them to generate a plan
  4. 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:

  1. No collaboration (team members can't share state)
  2. No locking (concurrent terraform apply causes corruption)
  3. No backup (delete the file = lose infrastructure tracking)
  4. 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:

  1. Both read current state: "2 instances exist"
  2. Both plan to create 1 more instance
  3. Both apply changes
  4. 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?

  1. What's the difference between list(string) and map(string)?

    • Lists are ordered, accessed by index ([0], [1]). Maps are key-value pairs, accessed by key (["dev"], ["prod"]).
  2. Which wins: terraform.tfvars or -var CLI flag?

    • CLI flags have highest precedence. -var overrides everything.
  3. Why mark outputs as sensitive = true?

    • Prevents secrets from appearing in console output and CI/CD logs. Forces explicit terraform output <name> to view.
  4. What happens if you lose the state file?

    • Terraform forgets your infrastructure exists. Next apply tries to recreate everything, causing conflicts.
  5. Where should production secrets be stored?

    • External secret stores (AWS Secrets Manager, HashiCorp Vault). Reference them via data sources at runtime.

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 modules
  • terraform plan: Preview changes before applying
  • terraform apply: Deploy infrastructure safely
  • terraform destroy: Tear down resources cleanly
  • terraform refresh: Sync state with reality
  • terraform 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

Series navigation:


This post is part of the "Terraform from Fundamentals to Production" series.