Cloud 25 min read

AWS OpenTofu #104: Variables, Outputs & Locals

Eliminate hardcoded values and copy-paste debt. Master typed input variables with custom validation rules, compute DRY naming conventions with locals, and expose critical attributes with outputs.

Mohammad Rizky Prawira portrait
Mohammad Rizky Prawira
terminal Series Guide • Part 4 of 15

AWS + OpenTofu Level 100: Infrastructure as Code from Zero

Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.

Look back at the code we wrote in Lab 102 and Lab 103:

provider "aws" {
  region = "ap-southeast-1"
}

resource "aws_s3_bucket" "app_vault" {
  bucket = "tofu-secure-vault-prawira-103"
}

This code works, but it suffers from a fatal architectural flaw: hardcoded values.

What happens when your engineering lead asks you to deploy the exact same secure storage vault in Europe (eu-central-1)? Or create isolated instances for development, staging, and production?

In immature teams, developers resort to “Copy-Paste Engineering”:

  1. Duplicate the entire folder into dev/, staging/, and prod/.
  2. Open every file and manually find-and-replace strings like "dev" with "prod".
  3. Miss a region name on line 42, causing a production database or bucket to be provisioned on the wrong continent.
  4. When a downstream application needs the bucket’s ARN, someone logs into the AWS console, copies the string manually, and pastes it into an email or Slack message.

This anti-pattern creates technical debt, configuration drift, and catastrophic deployment errors.

In this lab, we will transform hardcoded scripts into reusable, parameterized, enterprise-grade modules. You will master Input Variables with strict validation rules, leverage Locals to compute DRY (Don’t Repeat Yourself) naming conventions, and declare Outputs to expose critical infrastructure metadata programmatically to your CI/CD pipelines.


🎯 What You Will Learn

AWS Configuration ArchitectureOpenTofu Variables & InterpolationEngineering Standards
• Multi-environment naming standards• Typed variables (string, number, bool, map)• The multi-file standard (variables.tf, locals.tf, outputs.tf)
• Regional parameterization• Custom input validation rules with error messages• The 6-tier Variable Precedence hierarchy
• Enterprise tag consolidation• Dynamic string interpolation and locals• Creating safe terraform.tfvars.example files
• Exposing ARNs & endpoints for CI/CD• Exposing data with outputs (and sensitive flags)• Extracting JSON outputs with tofu output -json

💰 Estimated Lab Cost

ResourceConfigurationFree Tier CostStandard Account (Past Free Tier)
Amazon S3 Parameterized BucketStandard Tier (<1 hour)$0.00$0.00 (Zero stored data)
AWS API CallsPlan & Apply validation queries$0.00<$0.00005
OpenTofu CLILocal binary execution$0.00$0.00 (Free & Open Source)
Total Estimated CostComplete Hands-On Session$0.00< $0.01 (essentially $0.00)

[!NOTE] No Free Tier? No Problem: Testing variable inputs, outputs, and validation rules only invokes metadata and transient empty bucket operations. Non-Free Tier accounts incur <$0.0001 for the entire session.


📐 The Three Pillars of Parameterization

OpenTofu provides three distinct language constructs to manage data flow:

+-------------------------------------------------------------------------------+
|                              OPENTOFU DATA FLOW                               |
|                                                                               |
|  [ EXTERNAL INPUTS ]                                                          |
|    - terraform.tfvars                                                         |
|    - CLI flags: -var="env=prod"                                               |
|    - Environment vars: TF_VAR_env                                             |
|           │                                                                   |
|           ▼                                                                   |
|  [ INPUT VARIABLES (variables.tf) ]                                           |
|    - Define types, defaults, descriptions                                     |
|    - Enforce validation rules                                                 |
|           │                                                                   |
|           ▼                                                                   |
|  [ LOCAL VALUES (locals.tf) ]                                                 |
|    - Transform inputs, compute complex strings, merge tags                    |
|    - Eliminates duplicate calculations                                        |
|           │                                                                   |
|           ▼                                                                   |
|  [ RESOURCES (main.tf) ]                                                      |
|    - Provisions cloud infrastructure using var.* and local.*                  |
|           │                                                                   |
|           ▼                                                                   |
|  [ OUTPUT VALUES (outputs.tf) ]                                               |
|    - Expose ARNs, DNS names, and IDs for apps, users, and CI/CD pipelines     |
+-------------------------------------------------------------------------------+

1. Variables (var.<name>)

  • Analogous to: Function arguments or API parameters.
  • Purpose: Allow consumers to customize behavior without altering the underlying code.
  • Example: Changing environment = "staging" to environment = "production".

2. Locals (local.<name>)

  • Analogous to: Local constants or intermediate variables within a function.
  • Purpose: Calculate derived values once and reuse them across multiple resources.
  • Example: Constructing a unified naming string: "${var.project}-${var.environment}-${var.region_short}-vault".

3. Outputs (output "<name>")

  • Analogous to: Function return values.
  • Purpose: Expose data from your infrastructure so other configurations, automated scripts, or humans can consume them.
  • Example: Returning the provisioned bucket’s ARN or regional domain name.

📁 Step 1: Set Up Lab Directory Structure

Navigate to your ~/opentofu-labs repository and create the dedicated directory for Lab 104:

cd ~/opentofu-labs
mkdir -p lab-104-variables-outputs
cd lab-104-variables-outputs

We will organize our configuration into the standard multi-file layout used by professional cloud engineering teams:

lab-104-variables-outputs/
├── providers.tf             # Engine and AWS Provider setup
├── variables.tf             # Input parameter definitions & validation
├── locals.tf                # Computed naming strings & merged tags
├── main.tf                  # Infrastructure declarations (S3 vault)
├── outputs.tf               # Exported resource metadata
└── terraform.tfvars.example # Sample input values for documentation

⚙️ Step 2: Configure Providers (providers.tf)

Create providers.tf. Notice how we replace the hardcoded region with a variable reference: var.aws_region:

terraform {
  required_version = ">= 1.6.0"

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

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = local.default_tags
  }
}

📝 Step 3: Define Typed Input Variables (variables.tf)

Create variables.tf.

In OpenTofu, every variable should include a type, a meaningful description, and where appropriate, a default value and validation block:

# 1. AWS Region
variable "aws_region" {
  type        = string
  description = "The target AWS region for resource deployment."
  default     = "ap-southeast-1"
}

# 2. Project Name
variable "project_name" {
  type        = string
  description = "The name of the project, used in resource naming prefixes."
  default     = "tofu-vault"

  validation {
    condition     = length(var.project_name) > 3 && length(var.project_name) <= 20
    error_message = "project_name must be between 4 and 20 characters long."
  }
}

# 3. Environment Name with Strict Allowed Values
variable "environment" {
  type        = string
  description = "Target deployment environment (dev, staging, or prod)."
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: 'dev', 'staging', or 'prod'."
  }
}

# 4. Storage Purpose Identifier
variable "storage_purpose" {
  type        = string
  description = "Functional identifier for the bucket (e.g., media, logs, backups)."
  default     = "uploads"
}

# 5. Enable Versioning Toggle
variable "enable_versioning" {
  type        = bool
  description = "Toggle to enable or suspend S3 bucket versioning."
  default     = true
}

# 6. Additional Custom Tags
variable "extra_tags" {
  type        = map(string)
  description = "Optional additional tags to merge with standard resource tags."
  default     = {}
}

Deep Dive: Custom Input Validation

Notice the validation block inside variable "environment":

validation {
  condition     = contains(["dev", "staging", "prod"], var.environment)
  error_message = "environment must be one of: 'dev', 'staging', or 'prod'."
}

If a developer accidentally sets environment = "sandbox" or environment = "production", OpenTofu immediately halts before making any API requests, displaying your custom error message. This prevents invalid configurations before they can cause damage.


🧩 Step 4: Compute Dynamic Values (locals.tf)

Instead of repeatedly concatenating strings in resource blocks, compute them once in locals.tf.

Create locals.tf:

locals {
  # Standardized prefix for all resources: e.g., "tofu-vault-dev-ap-southeast-1"
  name_prefix = "${var.project_name}-${var.environment}"

  # S3 bucket names must be lowercase and cannot contain underscores
  # We append a random-like static identifier to maintain global uniqueness
  # (Replace 'prawira' with your initials/unique string)
  bucket_name = lower("${local.name_prefix}-${var.storage_purpose}-prawira-104")

  # Standardized baseline tags applied to every resource
  standard_tags = {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "OpenTofu"
    LabPart     = "104"
  }

  # Merge standard tags with any caller-supplied extra_tags
  default_tags = merge(local.standard_tags, var.extra_tags)
}

Why Locals Are Essential

  1. DRY Compliance: If you ever need to change your organization’s naming convention from ${project}-${environment} to ${environment}-${project}, you change a single line in locals.tf, and every resource updates automatically.
  2. Built-in Functions: Notice the use of lower() to guarantee the bucket name complies with AWS S3’s lowercase requirement, and merge() to combine tag maps cleanly.

🏗️ Step 5: Declare Infrastructure (main.tf)

Now write main.tf. Notice how clean and readable the resource declarations become when all hardcoded logic is abstracted away:

# 1. Base S3 Bucket
resource "aws_s3_bucket" "vault" {
  bucket        = local.bucket_name
  force_destroy = true

  tags = {
    Name    = local.bucket_name
    Purpose = var.storage_purpose
  }
}

# 2. Ownership Controls (BucketOwnerEnforced)
resource "aws_s3_bucket_ownership_controls" "vault" {
  bucket = aws_s3_bucket.vault.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

# 3. Block Public Access (The 4 Golden Rules)
resource "aws_s3_bucket_public_access_block" "vault" {
  bucket = aws_s3_bucket.vault.id

  block_public_acls       = true
  ignore_public_acls      = true
  block_public_policy     = true
  restrict_public_buckets = true

  depends_on = [aws_s3_bucket_ownership_controls.vault]
}

# 4. S3 Bucket Versioning with Dynamic Status
resource "aws_s3_bucket_versioning" "vault" {
  bucket = aws_s3_bucket.vault.id

  versioning_configuration {
    # Ternary expression: if var.enable_versioning is true -> "Enabled", else -> "Suspended"
    status = var.enable_versioning ? "Enabled" : "Suspended"
  }
}

# 5. Default SSE-S3 AES-256 Encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "vault" {
  bucket = aws_s3_bucket.vault.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
    bucket_key_enabled = true
  }
}

Notice line 35:

status = var.enable_versioning ? "Enabled" : "Suspended"

We use a ternary expression (condition ? true_val : false_val) to dynamically toggle versioning based on a boolean input variable!


📤 Step 6: Expose Infrastructure Data (outputs.tf)

When your infrastructure finishes provisioning, how do your microservices or teammates find the bucket?

They should never have to open the AWS Console to look it up. Instead, declare Outputs.

Create outputs.tf:

output "bucket_id" {
  description = "The physical name of the S3 bucket."
  value       = aws_s3_bucket.vault.id
}

output "bucket_arn" {
  description = "The Amazon Resource Name (ARN) of the provisioned bucket."
  value       = aws_s3_bucket.vault.arn
}

output "bucket_domain_name" {
  description = "The regional domain name of the bucket for application uploads."
  value       = aws_s3_bucket.vault.bucket_regional_domain_name
}

output "versioning_enabled" {
  description = "Current versioning status of the storage bucket."
  value       = aws_s3_bucket_versioning.vault.versioning_configuration[0].status
}

# Example of a sensitive output (masked in console outputs)
output "security_posture" {
  description = "Summary of security guardrails applied."
  value = {
    encryption_algorithm = "AES256"
    public_access_block  = "Enforced (4/4)"
    ownership_model      = "BucketOwnerEnforced"
  }
}

📄 Step 7: Document Input Values (terraform.tfvars.example)

Never commit your actual terraform.tfvars containing private secrets or account-specific overrides. Instead, provide a committed template named terraform.tfvars.example:

# Sample configuration for production deployment
aws_region        = "ap-southeast-1"
project_name      = "tofu-vault"
environment       = "prod"
storage_purpose   = "assets"
enable_versioning = true

extra_tags = {
  CostCenter = "FIN-4092"
  TeamOwner  = "PlatformCore"
}

🧪 Step 8: Test Validation & The 6 Precedence Levels

Now let’s test our configuration in the terminal.

Initialize the directory:

tofu init

1. Test Custom Validation (Deliberate Failure)

Let’s verify that our validation block in variables.tf actually catches invalid input:

tofu plan -var="environment=invalid-env"

Output:

╷
│ Error: Invalid value for variable
│ 
│   on variables.tf line 23:
│   23: variable "environment" {
│ 
│ environment must be one of: 'dev', 'staging', or 'prod'.
│ 
│ This was checked by the validation rule at variables.tf:28,3-13.
╵

OpenTofu rejected the command instantly. Your live AWS infrastructure was never touched, and bad data was prevented from entering state.


📊 Understanding Variable Precedence

When you define a variable, OpenTofu determines its final value by checking sources in the following strict order (from lowest to highest priority):

HIGHEST PRECEDENCE (Overrides everything)
  ▲
  │  6. CLI Arguments: tofu apply -var="environment=prod"
  │  5. Environment Variables: export TF_VAR_environment="staging"
  │  4. Command-line Var Files: tofu apply -var-file="secrets.tfvars"
  │  3. Auto Var Files: *.auto.tfvars or *.auto.tfvars.json (Alphabetical)
  │  2. Default Var File: terraform.tfvars or terraform.tfvars.json
  │  1. Variable Default: default = "dev" in variables.tf
  │
LOWEST PRECEDENCE

2. Deploy Using Default Values

Run tofu plan without flags:

tofu plan

Notice the calculated bucket name in the plan output:

+ resource "aws_s3_bucket" "vault" {
    + bucket = "tofu-vault-dev-uploads-prawira-104"
    ...

OpenTofu used the default values (project_name = "tofu-vault", environment = "dev", storage_purpose = "uploads").

3. Deploy for Production Using -var

Now preview what happens if we pass variables for production:

tofu plan -var="environment=prod" -var="storage_purpose=backups"

Notice the new computed bucket name:

+ resource "aws_s3_bucket" "vault" {
    + bucket = "tofu-vault-prod-backups-prawira-104"
    ...

Without changing a single line of infrastructure code, our configuration adapted dynamically.


⚡ Step 9: Provision & Inspect Outputs

Apply the configuration using our development defaults:

tofu apply

Type yes to confirm.

Once creation finishes, examine the terminal output:

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.

Outputs:

bucket_arn = "arn:aws:s3:::tofu-vault-dev-uploads-prawira-104"
bucket_domain_name = "tofu-vault-dev-uploads-prawira-104.s3.ap-southeast-1.amazonaws.com"
bucket_id = "tofu-vault-dev-uploads-prawira-104"
security_posture = {
  "encryption_algorithm" = "AES256"
  "ownership_model" = "BucketOwnerEnforced"
  "public_access_block" = "Enforced (4/4)"
}
versioning_enabled = "Enabled"

Querying Outputs via CLI

Outputs are not just for display; they can be queried at any time without re-running an apply:

# Get a single output value as raw text
tofu output -raw bucket_id
# Output: tofu-vault-dev-uploads-prawira-104

# Get all outputs formatted as clean JSON (perfect for CI/CD scripting!)
tofu output -json

Output:

{
  "bucket_arn": {
    "sensitive": false,
    "type": "string",
    "value": "arn:aws:s3:::tofu-vault-dev-uploads-prawira-104"
  },
  "bucket_domain_name": {
    "sensitive": false,
    "type": "string",
    "value": "tofu-vault-dev-uploads-prawira-104.s3.ap-southeast-1.amazonaws.com"
  },
  "bucket_id": {
    "sensitive": false,
    "type": "string",
    "value": "tofu-vault-dev-uploads-prawira-104"
  }
}

Your automated deployment scripts (GitHub Actions, GitLab CI, Python scripts) can now ingest your infrastructure’s dynamic values with a simple jq command:

BUCKET=$(tofu output -raw bucket_id)
aws s3 cp application.tar.gz s3://$BUCKET/

🧹 Step 10: Clean Teardown (tofu destroy)

Tear down your lab resources:

tofu destroy

Type yes when prompted. All five resources will be deleted cleanly.


🛠️ Common Troubleshooting & Gotchas

1. Error: Output refers to sensitive values

  • Cause: If an output value references a resource attribute that the provider marks as sensitive (such as a generated database password or IAM secret key), OpenTofu will refuse to display it unless you add sensitive = true to the output block.
  • Fix:
    output "db_password" {
      value     = aws_db_instance.main.password
      sensitive = true
    }

2. Validation Block Errors with Multiple Conditions

  • Rule: A validation block can only contain a single condition expression. If you have multiple rules, combine them using logical operators (&& or ||):
    validation {
      condition     = length(var.name) > 3 && length(var.name) < 20
      error_message = "name length must be between 4 and 19 characters."
    }

3. Missing Quotes in CLI -var Flags

When passing maps or complex strings on the command line in bash/zsh, remember to quote properly:

# Correct:
tofu plan -var='extra_tags={"Owner"="DevOps"}'

# Incorrect (will cause shell syntax error):
tofu plan -var=extra_tags={Owner=DevOps}

🏋️ Hands-On Challenge

Solidify your understanding of locals and outputs:

  1. Add a Region Shortcode Local: In locals.tf, use a map lookup or replace() function to transform the region name into an abbreviated shortcode (e.g., ap-southeast-1 becomes apse1, us-east-1 becomes use1).
  2. Update local.bucket_name to include the region shortcode: "${var.project_name}-${var.environment}-${local.region_short}-${var.storage_purpose}-prawira"
  3. Run tofu plan and observe how the bucket name updates seamlessly.

📖 Summary & What You Learned

In this lab, you eliminated copy-paste debt and established professional IaC parameterization standards:

  • Separation of Concerns: Split your codebase into variables.tf, locals.tf, main.tf, and outputs.tf.
  • Input Validation: Enforced strict validation rules to catch configuration typos before touching cloud APIs.
  • Dynamic Locals: Used locals to compute standardized naming schemes and merge organizational tags cleanly.
  • Ternary Logic: Used inline conditional expressions (var.enable_versioning ? "Enabled" : "Suspended") to dynamically alter resource configurations.
  • Exporting Metadata: Declared outputs to expose bucket ARNs, names, and domain endpoints for programmatic automation.
  • Variable Precedence: Understood how CLI flags, environment variables, and .tfvars files interact.

🚀 What’s Next? (Lab 105)

So far, all the values we’ve fed into OpenTofu have come from variables we typed ourselves. But what if your infrastructure needs to query information that already exists in AWS—such as the ID of the latest official Amazon Linux AMI, your AWS Account ID, or the list of active Availability Zones?

In [AWS OpenTofu #105: Data Sources & AWS Discovery], we will:

  • Discover AWS resources dynamically using OpenTofu data blocks.
  • Query active Availability Zones without hardcoding zone names.
  • Look up the latest Amazon Linux 2023 AMI dynamically using search filters.

Related & Recommended Guides

Continue exploring related systems architectures and engineering field notes.