Table of Contents
HCL Fundamentals
You've been writing Terraform code for three parts now. But do you actually understand what you're writing?
If you've been copying examples without knowing why resource is different from data, or when to use list(string) versus map(string), this part fixes that. We're diving into HashiCorp Configuration Language (HCL) — the language that powers Terraform.
📦 Code Examples
Repository: terraform-hcl-tutorial-series This Part: Part 4 - HCL Syntax Examples
Get the working example:
git clone https://github.com/khuongdo/terraform-hcl-tutorial-series.git
cd terraform-hcl-tutorial-series
git checkout part-04
cd examples/part-04-hcl-fundamentals/
# Try the examples
terraform init
terraform plan
Why HCL Exists
HCL isn't JSON. It's not YAML either. HashiCorp built it specifically for infrastructure code, and that choice matters more than you might think.
JSON is verbose, doesn't support comments, and makes human readers suffer. YAML looks clean until you hit indentation hell and wonder why your pipeline failed because of an invisible space. HCL gives you something better: a language that reads like code but defines infrastructure.
What you get:
- Comments that actually survive in your files
- Expressions and functions instead of static strings
- Strong typing that catches errors before
terraform applydestroys your weekend - Interpolation that makes dynamic configurations possible
- Conditionals and loops for patterns that would be impossible in pure JSON
Here's the thing: understanding HCL changes how you write Terraform. You stop copy-pasting from Stack Overflow and start designing infrastructure.
How HCL Actually Works
Every Terraform file is built from three things: blocks, arguments, and expressions. That's it.
Blocks
A block is a container. It has a type, maybe some labels, and a body in curly braces:
<BLOCK_TYPE> "<LABEL>" "<LABEL>" {
# Arguments go here
<IDENTIFIER> = <EXPRESSION>
}
Real examples:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
}
variable "region" {
type = string
default = "us-west-2"
}
output "instance_ip" {
value = aws_instance.web.public_ip
}
The block types you'll use constantly:
resourcecreates infrastructuredatareads existing stuffvariabledeclares inputsoutputexposes valueslocalsdefines computed valuesmodulecalls reusable modulesterraformconfigures Terraform itselfprovidersets up cloud provider credentials
Arguments
Arguments assign values. Left side is the name, right side is an expression:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0" # String
instance_type = var.instance_type # Variable reference
count = 3 # Number
}
Convention: one argument per line. Makes diffs readable.
Expressions
This is where HCL gets useful. Expressions compute values:
# Literals
"hello"
42
true
# References
var.instance_type
aws_vpc.main.id
# Math
var.instance_count * 2
# String interpolation
"Instance: ${var.name}"
# Conditionals
var.env == "prod" ? "t3.large" : "t3.micro"
# Functions
file("${path.module}/userdata.sh")
jsonencode({ name = "app", port = 8080 })
# Loops
[for s in var.subnets : s.id]
Instead of hardcoding everything, you compute values based on context. That's the power move.
The Type System
HCL has strong types. This is good because it catches errors early, not during a 3 AM deployment.
Primitive Types
String — text values
variable "region" {
type = string
default = "us-west-2"
}
# Multi-line strings use heredoc
variable "user_data" {
type = string
default = <<-EOF
#!/bin/bash
echo "Hello, World!"
EOF
}
Number — integers and floats (HCL doesn't care which)
variable "instance_count" {
type = number
default = 3
}
variable "disk_size" {
type = number
default = 100.5
}
Bool — true or false
variable "enable_monitoring" {
type = bool
default = true
}
# Common pattern: conditional resources
resource "aws_cloudwatch_alarm" "cpu" {
count = var.enable_monitoring ? 1 : 0
# ...
}
Complex Types
List — ordered collection, same type for all elements
variable "availability_zones" {
type = list(string)
default = ["us-west-2a", "us-west-2b", "us-west-2c"]
}
# Access by index
locals {
first_az = var.availability_zones[0]
}
# Loop with count
resource "aws_subnet" "public" {
count = length(var.availability_zones)
availability_zone = var.availability_zones[count.index]
cidr_block = "10.0.${count.index}.0/24"
}
Map — key-value pairs (keys are always strings)
variable "tags" {
type = map(string)
default = {
Environment = "production"
Owner = "platform-team"
CostCenter = "engineering"
}
}
# Access by key
locals {
env = var.tags["Environment"]
}
# Merge maps together
locals {
all_tags = merge(
var.tags,
{ ManagedBy = "terraform" }
)
}
Set — unordered, unique values
variable "allowed_ports" {
type = set(number)
default = [80, 443, 8080]
}
# Use with for_each
resource "aws_security_group_rule" "ingress" {
for_each = var.allowed_ports
type = "ingress"
from_port = each.value
to_port = each.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
Object — structured data with typed attributes
variable "server_config" {
type = object({
instance_type = string
ami_id = string
root_volume = object({
size = number
type = string
})
tags = map(string)
})
default = {
instance_type = "t3.micro"
ami_id = "ami-0c55b159cbfafe1f0"
root_volume = {
size = 20
type = "gp3"
}
tags = {
Name = "web-server"
}
}
}
# Access nested attributes
locals {
volume_size = var.server_config.root_volume.size
}
Tuple — fixed-length list with specific types per position
variable "connection_info" {
type = tuple([string, number, bool])
# Must be exactly: [string, number, bool]
default = ["db.example.com", 5432, true]
}
locals {
host = var.connection_info[0]
port = var.connection_info[1]
use_ssl = var.connection_info[2]
}
Any — accepts anything (use sparingly)
variable "custom_config" {
type = any
default = {}
}
Avoid any unless you really need it. It bypasses type checking and makes debugging painful. If you have structured data, use object({...}) instead.
String Interpolation and Directives
Embed expressions in strings with ${}:
locals {
region = "us-west-2"
environment = "production"
bucket_name = "${var.project}-${local.environment}-logs"
# Result: "myapp-production-logs"
}
String directives (%{}) let you use control flow inside strings:
locals {
user_data = <<-EOF
#!/bin/bash
%{ for ip in var.server_ips ~}
echo "Server: ${ip}"
%{ endfor ~}
EOF
}
Conditionals in strings:
locals {
greeting = <<-EOT
Hello, ${var.username}!
%{ if var.is_admin }
You have admin privileges.
%{ else }
You have standard access.
%{ endif }
EOT
}
Operators
Math and logic work like most languages:
# Arithmetic
var.count + 5
var.price * 1.2
var.total / var.quantity
# Comparison
var.count > 10
var.env == "prod"
var.enabled != false
# Logical
var.is_prod && var.enable_monitoring
var.is_dev || var.is_staging
!var.disabled
Built-In Functions
Terraform gives you 100+ functions. You can't write custom ones — you work with what's provided. Here are the ones you'll actually use.
File Functions
file() reads a file as a string:
locals {
user_data = file("${path.module}/scripts/init.sh")
}
filebase64() reads a file as base64:
resource "aws_s3_object" "config" {
bucket = aws_s3_bucket.app.id
key = "config.json"
content = filebase64("${path.module}/config.json")
}
templatefile() renders templates with variables:
# templates/userdata.tpl
#!/bin/bash
echo "Environment: ${environment}"
echo "Region: ${region}"
# main.tf
locals {
user_data = templatefile("${path.module}/templates/userdata.tpl", {
environment = var.environment
region = var.region
})
}
Path References
path.module # Current module directory
path.root # Root module directory
path.cwd # Current working directory
Encoding Functions
jsonencode() converts HCL values to JSON:
locals {
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "s3:GetObject"
Resource = "arn:aws:s3:::my-bucket/*"
}]
})
}
jsondecode() parses JSON strings:
locals {
config_data = jsondecode(file("config.json"))
api_url = local.config_data.api.url
}
yamlencode() and yamldecode() do the same for YAML:
locals {
deployment = yamldecode(file("k8s/deployment.yaml"))
replicas = local.deployment.spec.replicas
}
Collection Functions
length() counts elements:
length(["a", "b", "c"]) # 3
length({ key = "value" }) # 1
concat() merges lists:
concat(["a", "b"], ["c", "d"]) # ["a", "b", "c", "d"]
merge() combines maps:
merge(
{ name = "app" },
{ env = "prod" }
)
# Result: { name = "app", env = "prod" }
lookup() gets a map value with a fallback:
lookup(var.tags, "Environment", "unknown")
# Returns var.tags["Environment"] if it exists, else "unknown"
keys() and values() extract from maps:
keys({ a = 1, b = 2 }) # ["a", "b"]
values({ a = 1, b = 2 }) # [1, 2]
element() gets an item by index (wraps around):
element(["a", "b", "c"], 0) # "a"
element(["a", "b", "c"], 5) # "c" (5 % 3 = 2)
contains() checks if a list has a value:
contains(["prod", "staging"], var.env) # true/false
distinct() removes duplicates:
distinct(["a", "b", "a", "c"]) # ["a", "b", "c"]
flatten() un-nests lists:
flatten([["a", "b"], ["c", "d"]]) # ["a", "b", "c", "d"]
sort() sorts lists:
sort(["c", "a", "b"]) # ["a", "b", "c"]
String Functions
split() and join() convert between strings and lists:
split(",", "a,b,c") # ["a", "b", "c"]
join("-", ["a", "b"]) # "a-b"
upper() and lower() change case:
upper("hello") # "HELLO"
lower("WORLD") # "world"
replace() swaps substrings:
replace("hello-world", "-", "_") # "hello_world"
trimspace() removes whitespace:
trimspace(" hello ") # "hello"
format() does printf-style formatting:
format("Instance %03d in %s", 42, "us-west-2")
# "Instance 042 in us-west-2"
Conditionals
The ternary operator is your friend:
condition ? true_value : false_value
In practice:
resource "aws_instance" "web" {
instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"
tags = {
Environment = var.environment == "prod" ? "Production" : "Development"
}
}
You can chain them, but it gets messy fast. Use locals instead:
locals {
instance_type = (
var.environment == "prod" ? "t3.large" :
var.environment == "staging" ? "t3.medium" :
"t3.micro"
)
}
Conditional resources with count:
resource "aws_cloudwatch_alarm" "cpu" {
count = var.enable_monitoring ? 1 : 0
# Creates 1 if true, 0 if false (no resource)
# ...
}
For Expressions and Loops
For expressions transform collections. Think list comprehensions in Python or map/filter in JavaScript.
Transform Lists
variable "names" {
default = ["alice", "bob", "charlie"]
}
locals {
# Convert to uppercase
upper_names = [for name in var.names : upper(name)]
# ["ALICE", "BOB", "CHARLIE"]
# Filter and transform
short_names = [for name in var.names : name if length(name) < 6]
# ["alice", "bob"]
}
Transform Maps
variable "instances" {
default = {
web1 = "t3.micro"
web2 = "t3.small"
db = "t3.large"
}
}
locals {
# Transform values
instance_sizes = {
for name, type in var.instances :
name => upper(type)
}
# { web1 = "T3.MICRO", web2 = "T3.SMALL", db = "T3.LARGE" }
# Filter map
web_instances = {
for name, type in var.instances :
name => type
if substr(name, 0, 3) == "web"
}
# { web1 = "t3.micro", web2 = "t3.small" }
}
Extract from Objects
variable "servers" {
type = list(object({
name = string
size = string
}))
default = [
{ name = "web", size = "small" },
{ name = "db", size = "large" },
]
}
locals {
# Extract attribute
server_names = [for s in var.servers : s.name]
# ["web", "db"]
# Build map from list
server_map = {
for s in var.servers :
s.name => s.size
}
# { web = "small", db = "large" }
}
Resource Iteration with for_each
Create multiple resources from a set:
variable "buckets" {
type = set(string)
default = ["logs", "backups", "artifacts"]
}
resource "aws_s3_bucket" "app" {
for_each = var.buckets
bucket = "${var.project}-${each.key}"
tags = {
Name = each.value
}
}
# Reference: aws_s3_bucket.app["logs"].id
Or from a map:
variable "environments" {
type = map(object({
instance_type = string
instance_count = number
}))
default = {
dev = {
instance_type = "t3.micro"
instance_count = 1
}
prod = {
instance_type = "t3.large"
instance_count = 3
}
}
}
module "app" {
for_each = var.environments
source = "./modules/app"
environment = each.key
instance_type = each.value.instance_type
instance_count = each.value.instance_count
}
Use for_each when items have unique identifiers (map keys, set values). Resources get addressed by key: aws_instance.web["server1"]
Use count for simple repetition with numeric indexes. Resources get addressed by index: aws_instance.web[0]
Prefer for_each when possible. It's safer when removing items from the middle of a list because resources don't get renumbered.
Putting It All Together
Let's build something real that uses everything we've covered:
# variables.tf
variable "environment" {
type = string
default = "dev"
}
variable "allowed_ingress_rules" {
type = list(object({
port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
default = [
{
port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTP from anywhere"
},
{
port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS from anywhere"
},
{
port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
description = "SSH from internal"
}
]
}
# main.tf
locals {
# Common tags
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = "web-app"
CostCenter = var.environment == "prod" ? "production" : "development"
}
# Filter SSH in production
ingress_rules = [
for rule in var.allowed_ingress_rules :
rule
if !(var.environment == "prod" && rule.port == 22)
]
# Generate rule names for for_each
rule_names = {
for idx, rule in local.ingress_rules :
"${rule.protocol}-${rule.port}" => rule
}
}
resource "aws_security_group" "web" {
name = "${var.environment}-web-sg"
description = "Security group for ${var.environment} web servers"
tags = merge(
local.common_tags,
{ Name = "${var.environment}-web-sg" }
)
}
resource "aws_security_group_rule" "ingress" {
for_each = local.rule_names
security_group_id = aws_security_group.web.id
type = "ingress"
from_port = each.value.port
to_port = each.value.port
protocol = each.value.protocol
cidr_blocks = each.value.cidr_blocks
description = each.value.description
}
# outputs.tf
output "security_group_id" {
value = aws_security_group.web.id
description = "ID of the web security group"
}
output "allowed_ports" {
value = [for rule in local.ingress_rules : rule.port]
description = "List of allowed ingress ports"
}
What's happening:
- Conditional tags based on environment
- Filtering rules (no SSH in production)
- Converting list to map for
for_each - Creating dynamic resources from data
- Computed outputs from final rule set
This is infrastructure as real code. Not configuration files — actual programming.
Test Your Understanding
Before moving on, answer these:
- What's the difference between an argument and an expression?
- When would you use
list(string)versusset(string)? - How do you read a file and parse it as JSON in one line?
- Why is
for_eachsafer thancountfor managing multiple resources? - What happens if you access
var.tags.Environmentwhen the key doesn't exist? - How would you filter a list to only include items longer than 5 characters?
- What's the syntax for multi-line strings with conditionals?
- Can you define custom functions in HCL?
Answers:
- An argument is
key = value; an expression is the value (the right side) list(string)preserves order and allows duplicates;set(string)is unordered and enforces uniquenessjsondecode(file("config.json"))for_eachuses stable keys;countuses indexes that shift when items are removed- Error. Use
lookup(var.tags, "Environment", "default")for safety [for s in var.items : s if length(s) > 5]- Use
%{ if condition }...%{ endif }inside heredoc strings - No. HCL only provides built-in functions. Use modules for reusability
What's Next
You now understand how HCL actually works. In Part 5, we'll use this knowledge to build flexible, reusable configurations with variables and outputs.
You'll learn:
- Input variable validation and constraints
- Sensitive variable handling
- Output dependencies and data flow
- Variable precedence and override strategies
- Complex variable structures for real-world scenarios
The difference? You won't just copy variable examples anymore. You'll design variable schemas that enforce correctness at the type level.
Series navigation:
- Part 1: Why Infrastructure as Code?
- Part 2: Setting Up Terraform
- Part 3: Your First Cloud Resource
- Part 4: HCL Fundamentals (You are here)
- Part 5: Variables, Outputs & State
- Part 6: Core Terraform Workflow
- Part 7: Modules for Organization (Coming soon)
- Part 8: Multi-Cloud Patterns (Coming soon)
- Part 9: State Management & Team Workflows (Coming soon)
- Part 10: Testing & Validation (Coming soon)
- Part 11: Security & Secrets Management (Coming soon)
- Part 12: Production Patterns & DevSecOps (Coming soon)