Tannerthings
foundation 30 minutes beginner

Tutorial 03: Variables and Outputs - The Contract Between Modules

Stop hardcoding values and learn how variables prevent configuration drift across environments

Published January 29, 2025
Updated December 11, 2025
7 min read (1,220 words)
View Code on GitHub

Prerequisites

Complete these tutorials first: Tutorial 02: The Plan-Apply Contract

Brutal Truth Up Front

Hardcoded values are technical debt. Every time you copy-paste a resource definition for dev/staging/prod with slightly different values, you’re creating three separate configurations that will drift.

Variables aren’t about “making code reusable.” They’re about defining contracts that prevent drift and enable testing without modifying code.

Prerequisites

  • Completed Tutorials 01-02
  • Understanding of Terraform resource creation
  • AWS account configured

What You’ll Build

A security group module that accepts variables and returns outputs. You’ll see how hardcoded values force code changes, while variables enable environment-specific deployments without touching module code.

The Exercise

Step 1: The Wrong Way (Hardcoded Values)

Create main.tf with hardcoded values:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_security_group" "web" {
  name        = "web-server-sg"
  description = "Security group for web server"
  
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  
  tags = {
    Name = "web-server-sg"
    Environment = "dev"
  }
}

Apply this:

terraform init
terraform apply

Now you need a staging environment. What do you do? Copy the entire file and change values? Create a second resource block? Both approaches create drift over time.

Step 2: The Right Way (Variables)

Replace with parameterized version - create variables.tf:

variable "environment" {
  description = "Environment name (dev, staging, prod)"
  type        = string
  
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "allowed_cidr_blocks" {
  description = "CIDR blocks allowed to access the web server"
  type        = list(string)
  default     = ["0.0.0.0/0"]
}

variable "ingress_port" {
  description = "Port for inbound traffic"
  type        = number
  default     = 443
}

variable "tags" {
  description = "Additional tags to apply to resources"
  type        = map(string)
  default     = {}
}

Update main.tf to use variables:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

locals {
  common_tags = merge(
    {
      Name        = "web-server-sg-${var.environment}"
      Environment = var.environment
      ManagedBy   = "Terraform"
    },
    var.tags
  )
}

resource "aws_security_group" "web" {
  name        = "web-server-sg-${var.environment}"
  description = "Security group for ${var.environment} web server"
  
  ingress {
    from_port   = var.ingress_port
    to_port     = var.ingress_port
    protocol    = "tcp"
    cidr_blocks = var.allowed_cidr_blocks
  }
  
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  
  tags = local.common_tags
}

Create outputs.tf:

output "security_group_id" {
  description = "ID of the created security group"
  value       = aws_security_group.web.id
}

output "security_group_arn" {
  description = "ARN of the created security group"
  value       = aws_security_group.web.arn
}

output "security_group_name" {
  description = "Name of the created security group"
  value       = aws_security_group.web.name
}

Step 3: Variable Input Methods

Method 1: Command line

terraform apply -var="environment=dev" -var="ingress_port=8443"

Method 2: Variable files (recommended)

Create terraform.tfvars:

environment = "dev"
ingress_port = 443
allowed_cidr_blocks = ["10.0.0.0/8"]

tags = {
  CostCenter = "Engineering"
  Project    = "Web Platform"
}

Apply:

terraform apply

Terraform auto-loads terraform.tfvars. For different environments, use:

  • dev.tfvars
  • staging.tfvars
  • prod.tfvars

Then specify:

terraform apply -var-file="prod.tfvars"

Method 3: Environment variables

export TF_VAR_environment="staging"
export TF_VAR_ingress_port=8443
terraform apply

Step 4: Using Outputs

Outputs expose resource attributes for:

  1. Display to operators
  2. Consumption by other Terraform modules
  3. Integration with external systems

After apply, outputs appear:

Outputs:

security_group_id = "sg-0123456789abcdef"
security_group_arn = "arn:aws:ec2:us-east-1:123456789012:security-group/sg-0123456789abcdef"
security_group_name = "web-server-sg-dev"

Query specific outputs:

terraform output security_group_id

Use in another module:

module "security_group" {
  source = "./modules/security-group"
  
  environment = "prod"
}

resource "aws_instance" "web" {
  ami                    = "ami-12345"
  instance_type          = "t2.micro"
  vpc_security_group_ids = [module.security_group.security_group_id]
}

The Break (Intentional Failure Scenarios)

Scenario 1: Type Mismatch

Try passing the wrong type:

terraform apply -var="environment=123"

Validation catches it before creating anything.

Change validation temporarily to allow any string and apply with environment="production". The validation prevents this typo that would create a separate prod environment.

Scenario 2: Missing Required Variables

Remove the default from environment variable:

variable "environment" {
  description = "Environment name"
  type        = string
  # No default
}

Try applying without providing a value:

terraform apply

Terraform errors: “No value for required variable”. This prevents accidentally deploying to the wrong environment.

Scenario 3: Sensitive Data Exposure

Add a database password variable:

variable "db_password" {
  description = "Database password"
  type        = string
  sensitive   = true
}

Without sensitive = true, the password appears in plan output and logs. With it, Terraform masks the value.

The Recovery

If you’ve already deployed with hardcoded values, refactoring to variables requires care:

  1. Add variables without changing resource names/attributes
  2. Run plan to verify no changes (should show “No changes”)
  3. If plan shows changes, adjust variables to match current state exactly
  4. After zero-diff plan, you can safely modify variable values

Example recovery:

# Current state has "web-server-sg"
# Add variable with default matching current name
variable "sg_name_override" {
  default = "web-server-sg"  # Matches existing
}

# Use in resource
name = var.sg_name_override

# Plan shows no changes - safe to apply
# Now can change variable for future deploys

Exit Criteria

You understand this tutorial if you can:

  • Explain why hardcoded values cause configuration drift
  • Use variable validation to prevent invalid values
  • Implement multiple variable input methods appropriately
  • Design output values that other modules can consume
  • Refactor hardcoded resources to use variables without replacement

Key Lessons

  1. Variables define contracts - they specify what’s configurable
  2. Defaults enable progressive refinement - start simple, customize later
  3. Validation prevents errors before infrastructure changes
  4. Outputs create dependencies between modules
  5. Locals reduce repetition within a module without exposing variables

Why This Matters in Production

In FedRAMP High environments:

Before variables (3 separate codebases):

  • Dev uses 0.0.0.0/0 CIDR
  • Staging uses 10.0.0.0/8
  • Prod uses specific IP ranges

Someone updates dev, forgets staging. Drift occurs.

With variables (single codebase):

# dev.tfvars
allowed_cidr_blocks = ["0.0.0.0/0"]

# prod.tfvars  
allowed_cidr_blocks = ["52.46.128.0/19", "52.46.160.0/19"]

Same code, different configurations. Changes apply uniformly.

On the Air Force program, we use variables for:

  • Environment-specific CIDR ranges
  • Instance counts (dev=1, prod=3)
  • Backup retention (dev=7 days, prod=30 days)
  • Log shipping destinations
  • KMS key IDs for different classification levels

Real-World Pattern

Production variable structure:

variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Invalid environment."
  }
}

variable "vpc_cidr" {
  type = string
  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "Must be valid CIDR notation."
  }
}

variable "tags" {
  type = map(string)
  default = {}
}

locals {
  required_tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
    Compliance  = "FedRAMP-High"
  }
  
  all_tags = merge(local.required_tags, var.tags)
}

Every resource uses tags = local.all_tags ensuring compliance tags are always present.

Next Steps

Tutorial 04: Remote State and Locking - Learn how teams collaborate on Terraform without corrupting state files.

Cleanup

terraform destroy -var="environment=dev"

Additional Resources

Keywords

terraform variables terraform outputs input variables output values terraform modules

Need Help Implementing This?

I help government contractors and defense organizations modernize their infrastructure using Terraform and AWS GovCloud. With 15+ years managing DoD systems and active Secret clearance, I understand compliance requirements that commercial consultants miss.