Skip to main content

Terraform HCL — Variables, Outputs, Locals, and Expressions Explained

Master Terraform HCL syntax — writing reusable variables, capturing outputs, using locals for calculations, and writing expressions and conditionals.

26 Terms

Overview and What You Will Learn

A Terraform configuration that hardcodes every value — region, instance type, bucket name, environment tag — is a configuration you can only use once. The moment you need a second environment, you copy the entire folder, change twenty values, and now maintain two near-identical codebases. When a security group rule needs updating, you change it in two places. Miss one, and your environments drift.

Terraform variables are the solution. They turn your configuration into a template — write the infrastructure once, fill in the blanks differently for dev, staging, and production. This is the exact pattern Zerodha uses to run the same Terraform code across three environments without duplication.

In this lab you will master every HCL building block that makes configurations reusable and maintainable:

  • Input variables — parameters that callers pass in, with types, defaults, and validation
  • Output values — infrastructure attributes exposed after apply for humans and other configs to read
  • Local values — intermediate calculations you write once and reference many times
  • Expressions — conditionals, for loops, and built-in functions that add logic to your config
  • Dynamic blocks — generating repeated nested blocks programmatically from a list

By the end, you will refactor a hardcoded configuration into a fully parameterised one that works across any environment with a single variable file.

Why This Matters in Production

At Zerodha, one Terraform configuration manages infrastructure for dev, staging, and production. The resources are identical in structure — same VPC layout, same security group rules, same RDS schema. Only the values differ: t3.micro in dev, t3.large in prod; 20 GB storage in dev, 500 GB in prod; deletion protection off in dev, on in prod.

Variables make this possible. When a new port needs to be opened in the security group, an engineer changes one variable definition. Terraform applies it to all three environments through a CI/CD pipeline. No copy-paste. No missed environments. No drift.

Without variables, the team would maintain three copies of the same configuration — and every change would need to be made in triplicate, with a high chance of divergence.

Core Principles

Bash
+------------------------------------------+
| Value Sources (where values come from) |
| terraform.tfvars, TF_VAR_, -var flag |
+------------------------------------------+
|
v
+------------------------------------------+
| variables.tf (input declarations) |
| variable "environment" { type = string } |
| variable "instance_type" { type = string}|
+------------------------------------------+
|
v
+------------------------------------------+
| locals block (computed expressions) |
| name_prefix = "zerodha-${var.env}" |
| is_prod = var.environment == "prod" |
+------------------------------------------+
|
v
+------------------------------------------+
| main.tf (resources use var.x / local.x) |
| bucket = local.name_prefix |
| instance_type = local.instance_size |
+------------------------------------------+
|
v
+------------------------------------------+
| outputs.tf (expose attributes after apply|
| output "bucket_name" { value = ... } |
| output "instance_ip" { value = ... } |
+------------------------------------------+

Detailed Step-by-Step Practical Lab

This lab refactors a hardcoded configuration into a fully parameterised one. Start with this rigid configuration and improve it step by step.

Starting Point — The Hardcoded Configuration (Do Not Copy This Into Production)
HCL
resource "aws_s3_bucket" "data" {
bucket = "zerodha-data-dev" # hardcoded environment name
tags = {
Environment = "dev" # hardcoded tag
ManagedBy = "terraform"
}
}
resource "aws_instance" "app" {
ami = "ami-0f5ee92e2d63afc18"
instance_type = "t3.micro" # hardcoded — different for prod
tags = {
Name = "zerodha-app-dev" # hardcoded
Environment = "dev"
}
}

This config cannot be reused. Every environment change means editing the file directly. By the end of this lab, a single terraform apply -var-file=prod.tfvars will deploy the same code to production with the correct values.


Part 1 — Input Variables

Input variables are the parameters of your configuration. They separate the what (resource structure) from the how (specific values). Declare every variable in variables.tf.

Primitive Types

HCL
# variables.tf
# String — the most common type
variable "environment" {
description = "Deployment environment — controls resource sizing and naming"
type = string
default = "dev"
}
# Number — for counts, sizes, ports
variable "replica_count" {
description = "Number of EC2 instances to run behind the load balancer"
type = number
default = 2
}
# Bool — for feature flags
variable "enable_deletion_protection" {
description = "Prevent terraform destroy from deleting the RDS instance"
type = bool
default = false # set to true in prod — see prod.tfvars
}

Collection Types

HCL
# variables.tf (continued)
# list(string) — ordered collection, allows duplicates
variable "availability_zones" {
description = "AZs to spread subnets across for high availability"
type = list(string)
default = ["ap-south-1a", "ap-south-1b"]
}
# map(string) — key-value pairs, all values same type
variable "instance_types" {
description = "EC2 instance type to use in each environment"
type = map(string)
default = {
dev = "t3.micro" # cheapest — fine for development
staging = "t3.small" # more headroom for load testing
prod = "t3.large" # production traffic load
}
}
# set(string) — unordered collection, no duplicates
variable "allowed_cidrs" {
description = "IP ranges allowed to reach the application"
type = set(string)
default = ["10.0.0.0/8", "172.16.0.0/12"]
}

Structural Types

HCL
# variables.tf (continued)
# object — structured data with named attributes of different types
variable "database_config" {
description = "RDS configuration — engine, version, sizing, and backup"
type = object({
engine = string
engine_version = string
instance_class = string
allocated_storage_gb = number
multi_az = bool
backup_retention_days = number
})
default = {
engine = "postgres"
engine_version = "14.9"
instance_class = "db.t3.medium"
allocated_storage_gb = 20
multi_az = false # true in prod
backup_retention_days = 7
}
}
# list(object) — list of structured items (e.g., security group rules)
variable "ingress_rules" {
description = "Inbound traffic rules for the application security group"
type = list(object({
port = number
protocol = string
description = string
cidr_blocks = list(string)
}))
default = [
{
port = 443
protocol = "tcp"
description = "HTTPS from internet"
cidr_blocks = ["0.0.0.0/0"]
},
{
port = 8080
protocol = "tcp"
description = "Internal health checks from ALB"
cidr_blocks = ["10.0.0.0/8"]
}
]
}

Validation — Catching Mistakes Before Plan

HCL
# variables.tf (continued)
variable "aws_region" {
description = "AWS region for all resources — must be Mumbai or Virginia"
type = string
default = "ap-south-1"
# Validation runs at plan time before any API calls
# If the condition is false, error_message is shown and plan stops
validation {
condition = contains(["ap-south-1", "us-east-1"], var.aws_region)
error_message = "Region must be ap-south-1 (Mumbai) or us-east-1 (N. Virginia)."
}
}
variable "project_name" {
description = "Project slug — used as prefix in all resource names and tags"
type = string
validation {
# Only lowercase letters, numbers, and hyphens — no spaces or underscores
condition = can(regex("^[a-z0-9-]+$", var.project_name))
error_message = "Project name must be lowercase letters, numbers, and hyphens only."
}
validation {
# Must not exceed 20 characters — S3 bucket names have a 63-char limit
# and we append suffixes, so keep the prefix short
condition = length(var.project_name) <= 20
error_message = "Project name must be 20 characters or fewer."
}
}
# Sensitive variable — value is hidden in plan output and terminal logs
variable "db_master_password" {
description = "Master password for the RDS instance — never log this"
type = string
sensitive = true # plan output shows (sensitive) instead of the value
# WARNING: still stored in plaintext in state file
# Use Vault or Secrets Manager for true secret management
}

Part 2 — Passing Variable Values

Variables can be set in four ways. Terraform applies them in priority order — highest wins.

Bash
# ── Method 1: terraform.tfvars (auto-loaded, lowest priority) ─────────────
# Terraform automatically loads this file if it exists in the working directory
cat > terraform.tfvars << 'EOF'
project_name = "zerodha-api"
environment = "dev"
aws_region = "ap-south-1"
# Do NOT put db_master_password here — this file goes in Git
EOF
# ── Method 2: Named .tfvars file (explicit, per environment) ──────────────
cat > dev.tfvars << 'EOF'
environment = "dev"
replica_count = 1
enable_deletion_protection = false
database_config = {
engine = "postgres"
engine_version = "14.9"
instance_class = "db.t3.micro"
allocated_storage_gb = 20
multi_az = false
backup_retention_days = 3
}
EOF
cat > prod.tfvars << 'EOF'
environment = "prod"
replica_count = 3
enable_deletion_protection = true
database_config = {
engine = "postgres"
engine_version = "14.9"
instance_class = "db.r6g.large"
allocated_storage_gb = 500
multi_az = true
backup_retention_days = 30
}
EOF
# Apply using environment-specific variable file
terraform plan -var-file=prod.tfvars
terraform apply -var-file=prod.tfvars
# ── Method 3: Environment variables (for secrets and CI/CD) ───────────────
# Prefix any variable name with TF_VAR_ and Terraform reads it automatically
export TF_VAR_db_master_password="V3ryS3cur3P@ssw0rd"
export TF_VAR_environment="prod"
# ── Method 4: -var flag (highest priority, overrides everything) ──────────
terraform plan -var="environment=staging" -var="replica_count=2"

Variable Precedence — Highest to Lowest

Bash
+------------------------------------------+
| -var and -var-file flags | <- highest: overrides everything
+------------------------------------------+
|
+------------------------------------------+
| TF_VAR_ environment variables |
+------------------------------------------+
|
+------------------------------------------+
| *.auto.tfvars (alphabetical order) | <- auto-loaded
+------------------------------------------+
|
+------------------------------------------+
| terraform.tfvars | <- auto-loaded
+------------------------------------------+
|
+------------------------------------------+
| default values in variable {} blocks | <- lowest: used if nothing else set
+------------------------------------------+

Part 3 — Output Values

Outputs expose specific infrastructure attributes after apply. They serve two purposes: printing useful information for engineers reading the terminal, and making values available for other Terraform configurations to read via remote state.

HCL
# outputs.tf
# Basic output — string value
output "s3_bucket_name" {
description = "Name of the S3 data bucket — used in application config"
value = aws_s3_bucket.data.id # .id is the bucket name for S3
}
# ARN output — needed for IAM policy documents
output "s3_bucket_arn" {
description = "ARN of the S3 data bucket — use in IAM policy Resource fields"
value = aws_s3_bucket.data.arn
}
# Sensitive output — value hidden in terminal, accessible via terraform output -raw
output "db_connection_string" {
description = "PostgreSQL connection string for the application"
value = "postgres://app:${var.db_master_password}@${aws_db_instance.main.endpoint}/payments"
sensitive = true # hidden in terminal output — use terraform output -raw to read
}
# List output — multiple values from for_each
output "instance_ids" {
description = "EC2 instance IDs for all replicas — use in load balancer target group"
value = [for instance in aws_instance.app : instance.id]
}
# Map output — structured data for multiple related values
output "database_connection" {
description = "Database connection parameters for the application"
value = {
host = aws_db_instance.main.address
port = aws_db_instance.main.port
name = aws_db_instance.main.db_name
endpoint = aws_db_instance.main.endpoint
}
}
Bash
# After terraform apply — read outputs
terraform output # print all outputs
terraform output s3_bucket_name # print one specific output
terraform output -raw s3_bucket_name # raw value without quotes (use in scripts)
terraform output -json # all outputs as machine-readable JSON
# Read a sensitive output (requires explicit -raw flag)
terraform output -raw db_connection_string
# In a shell script — capture an output into a variable
BUCKET_NAME=$(terraform output -raw s3_bucket_name)
echo "Deploying to bucket: $BUCKET_NAME"

Part 4 — Local Values

A local value is a named expression you compute once and reference many times. Locals are not input parameters — callers cannot set them. They are internal to your configuration.

Use locals when:

  • You write the same expression ("${var.project}-${var.environment}") in more than one place
  • An expression is complex and naming it makes the intent clear
  • You want to build a common_tags map once and attach it to every resource
HCL
# locals.tf — or add a locals block in main.tf
locals {
# ── Naming ───────────────────────────────────────────────────────────────
# Single source of truth for the naming prefix
# Every resource name starts with this — zerodha-api-prod
name_prefix = "${var.project_name}-${var.environment}"
# ── Tagging ──────────────────────────────────────────────────────────────
# Apply these tags to every resource via tags = local.common_tags
# Saves writing the same tags block on every resource block
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
Repository = "github.com/zerodha/infrastructure"
CostCenter = "platform-engineering"
}
# ── Conditional sizing ────────────────────────────────────────────────────
# Look up instance type from the map variable using current environment as key
# Fallback to t3.micro if environment key not found in the map
ec2_instance_type = lookup(var.instance_types, var.environment, "t3.micro")
# Boolean flag for production-specific behaviour
is_production = var.environment == "prod"
# Enable multi-AZ for prod, single-AZ for everything else
rds_multi_az = local.is_production
# ── Computed names ────────────────────────────────────────────────────────
# These reference local.name_prefix — locals can reference each other
s3_bucket_name = "${local.name_prefix}-data" # zerodha-api-prod-data
rds_identifier = "${local.name_prefix}-postgres" # zerodha-api-prod-postgres
sg_name = "${local.name_prefix}-app-sg" # zerodha-api-prod-app-sg
log_group_name = "/aws/${local.name_prefix}/app" # /aws/zerodha-api-prod/app
# ── Account-scoped uniqueness ─────────────────────────────────────────────
# S3 bucket names must be globally unique — include account ID to guarantee it
unique_bucket_name = "${local.name_prefix}-data-${data.aws_caller_identity.current.account_id}"
}
HCL
# main.tf — using locals
resource "aws_s3_bucket" "data" {
bucket = local.unique_bucket_name # not var.project_name repeated everywhere
tags = local.common_tags # one line instead of six repeated tag blocks
}
resource "aws_db_instance" "main" {
identifier = local.rds_identifier
multi_az = local.rds_multi_az # true in prod, false elsewhere
instance_class = var.database_config.instance_class
tags = local.common_tags
}

Part 5 — Expressions and Built-In Functions
HCL
locals {
# ── Conditional (ternary) ─────────────────────────────────────────────────
# condition ? value_if_true : value_if_false
backup_retention = var.environment == "prod" ? 30 : 3
log_level = local.is_production ? "warn" : "debug"
# ── String functions ───────────────────────────────────────────────────────
upper_env = upper(var.environment) # "PROD"
lower_proj = lower(var.project_name) # "zerodha-api"
env_initial = substr(var.environment, 0, 1) # "p" (first character)
full_name = join("-", [var.project_name, var.environment]) # "zerodha-api-prod"
parts = split("-", "zerodha-api-prod") # ["zerodha", "api", "prod"]
trimmed = trimspace(" prod ") # "prod"
replaced = replace(var.project_name, "-", "_") # "zerodha_api" (underscore)
# ── List functions ─────────────────────────────────────────────────────────
az_count = length(var.availability_zones) # 2
first_az = element(var.availability_zones, 0) # "ap-south-1a"
sorted_azs = sort(var.availability_zones) # alphabetical
unique_azs = distinct(["ap-south-1a", "ap-south-1a", "ap-south-1b"]) # dedup
# Flatten a list of lists into a single list
all_cidrs = flatten([
["10.0.0.0/8", "172.16.0.0/12"],
var.extra_cidrs, # might be empty list — flatten handles it
])
# Coerce a list to a set (removes duplicates, loses order)
az_set = toset(var.availability_zones)
# ── Map functions ──────────────────────────────────────────────────────────
# Merge two maps — right side wins on key conflicts
full_tags = merge(
local.common_tags,
{ Name = local.name_prefix, Component = "api" }
)
tag_keys = keys(local.common_tags) # ["CostCenter", "Environment", ...]
tag_values = values(local.common_tags) # ["platform-engineering", "prod", ...]
# Lookup — read a map value with a fallback default
instance_size = lookup(var.instance_types, var.environment, "t3.micro")
# ── Number functions ───────────────────────────────────────────────────────
storage_with_buffer = ceil(var.database_config.allocated_storage_gb * 1.2) # 20% headroom
min_replicas = max(var.replica_count, 2) # never fewer than 2
# ── Type conversion ────────────────────────────────────────────────────────
# Convert string "true" to bool — useful when reading from env vars
feature_enabled = tobool("true")
}

Part 6 — For Expressions

For expressions transform lists and maps. They are like list comprehensions in Python or Array.map in JavaScript — but for HCL.

HCL
locals {
# ── List → List (transform) ───────────────────────────────────────────────
# Uppercase every environment name
upper_envs = [for env in ["dev", "staging", "prod"] : upper(env)]
# Result: ["DEV", "STAGING", "PROD"]
# Prepend project name to every AZ
az_labels = [for az in var.availability_zones : "${var.project_name}-${az}"]
# Result: ["zerodha-api-ap-south-1a", "zerodha-api-ap-south-1b"]
# ── List → List (filter) ──────────────────────────────────────────────────
# Only non-dev environments
non_dev_envs = [for env in ["dev", "staging", "prod"] : env if env != "dev"]
# Result: ["staging", "prod"]
# ── List → Map (index-keyed) ──────────────────────────────────────────────
# Convert AZ list to map for use with for_each
# for_each requires a map or set — lists do not work directly
az_index_map = { for idx, az in var.availability_zones : az => idx }
# Result: { "ap-south-1a" = 0, "ap-south-1b" = 1 }
# ── Map → Map (transform values) ─────────────────────────────────────────
# Add environment suffix to every instance type value
tagged_instances = {
for env, itype in var.instance_types : env => "${itype}-${env}"
}
# Result: { "dev" = "t3.micro-dev", "prod" = "t3.large-prod" }
# ── Map → List (extract values) ───────────────────────────────────────────
instance_type_list = [for env, itype in var.instance_types : itype]
# Result: ["t3.micro", "t3.small", "t3.large"]
}
# for_each using a for expression — create one subnet per AZ
resource "aws_subnet" "private" {
# Convert list to AZ→index map so each subnet has a stable key
for_each = { for idx, az in var.availability_zones : az => idx }
vpc_id = aws_vpc.main.id
availability_zone = each.key # "ap-south-1a"
# cidrsubnet(base, newbits, netnum) carves a /24 from a /16
cidr_block = cidrsubnet(var.vpc_cidr, 8, each.value + 10)
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-private-${each.key}"
Tier = "private"
})
}

Part 7 — Dynamic Blocks

Dynamic blocks generate repeated nested blocks from a list or map variable. Without them, you would write one ingress block per port — hardcoded, inflexible.

HCL
# variables.tf
variable "sg_ingress_rules" {
description = "Security group inbound rules — add ports without touching resource blocks"
type = list(object({
description = string
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = [
{
description = "HTTPS from internet"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
{
description = "Internal API from VPC"
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
},
{
description = "Prometheus scrape from monitoring subnet"
from_port = 9090
to_port = 9090
protocol = "tcp"
cidr_blocks = ["10.10.0.0/24"]
},
]
}
# main.tf
resource "aws_security_group" "app" {
name = local.sg_name
description = "Application security group for ${local.name_prefix}"
# Dynamic block generates one ingress {} block per item in var.sg_ingress_rules
# Add a new port by adding an item to the variable — no resource block changes
dynamic "ingress" {
for_each = var.sg_ingress_rules # iterator label defaults to block name ("ingress")
content {
description = ingress.value.description
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
# Static egress — always allow all outbound
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = local.common_tags
}

Part 8 — The Refactored Configuration

Here is the complete refactored main.tf using all the concepts above — the hardcoded version from the start of the lab, now fully parameterised:

HCL
# main.tf — refactored: parameterised, reusable, production-ready
data "aws_caller_identity" "current" {} # read AWS account ID
locals {
name_prefix = "${var.project_name}-${var.environment}"
unique_bucket_name = "${local.name_prefix}-data-${data.aws_caller_identity.current.account_id}"
ec2_instance_type = lookup(var.instance_types, var.environment, "t3.micro")
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket" "data" {
bucket = local.unique_bucket_name
tags = local.common_tags
}
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = var.environment == "prod" ? "Enabled" : "Suspended"
}
}
resource "aws_security_group" "app" {
name = "${local.name_prefix}-app-sg"
tags = local.common_tags
dynamic "ingress" {
for_each = var.sg_ingress_rules
content {
description = ingress.value.description
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "app" {
count = var.replica_count # 1 in dev, 3 in prod
ami = data.aws_ami.amazon_linux.id
instance_type = local.ec2_instance_type # from map variable
vpc_security_group_ids = [aws_security_group.app.id]
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-app-${count.index + 1}"
Index = tostring(count.index)
})
}

Apply for dev environment:

Bash
terraform apply -var-file=dev.tfvars

Apply the identical code for prod — only values change:

Bash
terraform apply -var-file=prod.tfvars

Production Best Practices and Common Pitfalls

  • Write a description for every variable without exception. Engineers reading a prod.tfvars file six months later have no other documentation. The description field is the inline docs.

  • Always add validation blocks to variables with restricted values. An invalid environment name caught at terraform plan time (before any API calls) saves debugging a confusing apply failure. Use contains() for enums, regex() for format checks, length() for size limits.

  • Never put secrets in .tfvars files that go into Git. Use TF_VAR_ environment variables for passwords and API keys. In CI/CD, pull secrets from Vault or AWS Secrets Manager and export them as TF_VAR_ before running Terraform.

  • Use sensitive = true for secret variables and outputs. This hides values from terminal output and plan logs. It does NOT encrypt them in the state file — that requires a properly secured remote backend.

  • Use locals instead of repeating the same expression. The pattern "${var.project}-${var.environment}" written in eight resource blocks is eight places to update when the naming convention changes. Write it once in locals.

  • Use for_each with maps instead of count with lists. If you use count = 3 to create three S3 buckets and later remove the middle one from the list, Terraform renumbers everything and may destroy and recreate buckets you did not intend to touch. for_each with a map gives each instance a stable key.

  • Commit per-environment .tfvars files to Git. Files like dev.tfvars, staging.tfvars, and prod.tfvars are configuration, not secrets. Having them in Git gives you a clear audit trail of what values each environment uses and when they changed.

  • Use terraform console to test expressions before putting them in config. The console gives you an interactive HCL evaluator — type upper("hello") or cidrsubnet("10.0.0.0/16", 8, 3) to test your expression before adding it to a resource argument.

Quick Reference and Troubleshooting Commands

Command What It Does
terraform output Print all output values after apply
terraform output -json Print all outputs as machine-readable JSON
terraform output -raw <name> Print one output value without quotes (for shell scripts)
terraform console Open interactive HCL expression evaluator
terraform validate Check configuration syntax without making API calls
terraform plan -var-file=prod.tfvars Plan using a specific variable file
terraform apply -var="env=prod" Override one variable on the command line
TF_VAR_db_password=secret terraform apply Pass sensitive variable via environment
Error Root Cause Fix
Error: Variables not allowed Using var.x inside a backend block Backend blocks cannot use variables — use partial config or hardcode
Error: Invalid value for variable A validation condition returned false Read the error_message in the validation block — fix the value
Error: Unsupported attribute Wrong key name in an object variable Check the object type definition in variables.tf
Error: Cannot use sensitive value here Sensitive var used in resource count or for_each Use a non-sensitive variable or use nonsensitive() carefully
Output shows (sensitive value) Output or variable has sensitive = true Use terraform output -raw <name> to read the actual value
Error: This object does not have an attribute named "x" Typo in object variable key Check spelling against the object type definition
terraform.tfvars not loading File not in working directory or misspelled Run ls to confirm file exists; check exact spelling
Variable default not respected Higher-priority source (env var or -var) overrides it Check for TF_VAR_ env vars: `env

Resources

Explore More in Terraform Fundamentals and Core Workflow

All 4 Topics

Frequently Asked Questions

Is Terraform HCL — Variables, Outputs, Locals, and Expressions Explained free to learn on DevOps Network?

Yes - this topic, like everything on DevOps Network, is 100% free with no paywall or sign-up gate.

What does the Terraform HCL — Variables, Outputs, Locals, and Expressions Explained topic cover?

Master Terraform HCL syntax — writing reusable variables, capturing outputs, using locals for calculations, and writing expressions and conditionals.