Posted on :: 2302 Words :: Tags: , ,

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 apply destroys 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:

  • resource creates infrastructure
  • data reads existing stuff
  • variable declares inputs
  • output exposes values
  • locals defines computed values
  • module calls reusable modules
  • terraform configures Terraform itself
  • provider sets 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 = {}
}

Type Safety Tip

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
}

for_each vs 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:

  1. Conditional tags based on environment
  2. Filtering rules (no SSH in production)
  3. Converting list to map for for_each
  4. Creating dynamic resources from data
  5. 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:

Checkpoint!

  1. What's the difference between an argument and an expression?
  2. When would you use list(string) versus set(string)?
  3. How do you read a file and parse it as JSON in one line?
  4. Why is for_each safer than count for managing multiple resources?
  5. What happens if you access var.tags.Environment when the key doesn't exist?
  6. How would you filter a list to only include items longer than 5 characters?
  7. What's the syntax for multi-line strings with conditionals?
  8. Can you define custom functions in HCL?

Answers:

  1. An argument is key = value; an expression is the value (the right side)
  2. list(string) preserves order and allows duplicates; set(string) is unordered and enforces uniqueness
  3. jsondecode(file("config.json"))
  4. for_each uses stable keys; count uses indexes that shift when items are removed
  5. Error. Use lookup(var.tags, "Environment", "default") for safety
  6. [for s in var.items : s if length(s) > 5]
  7. Use %{ if condition }...%{ endif } inside heredoc strings
  8. 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: