Tutorial 03: Variables and Outputs - The Contract Between Modules
Stop hardcoding values and learn how variables prevent configuration drift across environments
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.tfvarsstaging.tfvarsprod.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:
- Display to operators
- Consumption by other Terraform modules
- 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:
- Add variables without changing resource names/attributes
- Run plan to verify no changes (should show “No changes”)
- If plan shows changes, adjust variables to match current state exactly
- 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
- Variables define contracts - they specify what’s configurable
- Defaults enable progressive refinement - start simple, customize later
- Validation prevents errors before infrastructure changes
- Outputs create dependencies between modules
- 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/0CIDR - 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
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.