Skip to content
ioob.dev
Go back

Terraform Part 3 — HCL Syntax

· 6 min read
Terraform Series (3/15)
  1. Terraform Part 1 — What Is Terraform
  2. Terraform Part 2 — Installation and First Deploy
  3. Terraform Part 3 — HCL Syntax
  4. Terraform Part 4 — Variables and Outputs
  5. Terraform Part 5 — Providers
  6. Terraform Part 6 — Resources and Dependencies
  7. Terraform Part 7 — Data Sources and Import
  8. Terraform Part 8 — State Management
  9. Terraform Part 9 — Modules
  10. Terraform Part 10 — Loops and Conditionals
  11. Terraform Part 11 — Workspaces and Environment Separation
  12. Terraform Part 12 — Kubernetes and Helm Providers
  13. Terraform Part 13 — CI/CD Integration
  14. Terraform Part 14 — Testing and Policy
  15. Terraform Part 15 — Practical Patterns and Pitfalls
Table of contents

Table of contents

Knowing the Language

In Part 2, we already wrote .tf files. Words like resource, provider, and data appeared and things worked fine. But as you write more, questions pile up. Why are quotes used sometimes and not others? How do you embed variables inside strings? Can you iterate over a list to create multiple resources?

To answer these questions, we need to go through HCL itself. This part lays out the HCL syntax needed to use Terraform like a map. You don’t need to memorize every expression, but knowing “these tools exist” means you can solve most things with a quick search in practice.

What Is HCL

HCL (HashiCorp Configuration Language) is a configuration DSL created by HashiCorp. It’s shared across HashiCorp tools like Terraform, Vault, Consul, and Nomad. It’s JSON-compatible but supports comments and expressions, making it easier for humans to read.

HCL has only two basic units: blocks and arguments.

# One block
resource "aws_instance" "web" {
  # Arguments
  ami           = "ami-xxx"
  instance_type = "t3.micro"

  # Nested block
  tags = {
    Name = "web"
  }
}

That’s all there is to it. The entire file is a combination of blocks and arguments, and argument values contain expressions. Expressions are what make HCL “more than just a configuration language.”

Block Structure

A block looks like this.

<TYPE> "<LABEL1>" "<LABEL2>" {
  <argument> = <value>
  <nested block> { ... }
}

TYPE can be resource, variable, output, provider, module, data, terraform, locals, and others. The number of labels varies by block type.

# 0 labels — terraform, locals
terraform {
  required_version = ">= 1.5.0"
}

locals {
  environment = "dev"
}

# 1 label — provider, variable, output, module
provider "aws" { region = "ap-northeast-2" }

variable "instance_count" {
  type    = number
  default = 2
}

# 2 labels — resource, data
resource "aws_instance" "web" { ... }
data "aws_ami" "latest"     { ... }

resource and data take two labels: “type + name.” The type is the name defined by the provider (aws_instance, aws_s3_bucket), and the name is a logical identifier you assign. This name must be unique within the same module and is used when referencing from other resources.

Comments

There are three comment styles. Any of them works.

# Single-line comment (recommended)
// Single-line comment
/* Multi-line
   comment */

In practice, # is the most common. Some use // for consistency with other languages.

Basic Types

The basic types that can go into arguments are simple.

# String
name = "web-server"

# Number
count = 3
price = 1.5

# Boolean
enabled = true

# null — explicitly indicates no value
custom_value = null

If types are confusing, you can explicitly specify them (mainly used in variable blocks). Variables and the type system are covered separately in the next part.

Collections — list, map, set

There are three collection types.

# List (ordered, allows duplicates)
availability_zones = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]

# Map (key-value)
tags = {
  Name        = "web"
  Environment = "prod"
}

# Set (unordered, no duplicates) — no literal syntax, created with functions
unique_ports = toset([80, 443, 80])   # {80, 443}

Access patterns are similar to other languages.

first_az = var.availability_zones[0]       # List index
name     = var.tags["Name"]                # Map key
# or
name     = var.tags.Name                   # Dot notation (only when key follows identifier rules)

Lists and maps are used frequently, so it’s good to get comfortable with them.

String Interpolation

One of the most commonly used features in HCL. Place an expression inside ${...} and its value gets inserted into the string.

variable "environment" {
  default = "dev"
}

resource "aws_s3_bucket" "logs" {
  bucket = "myapp-logs-${var.environment}"   # myapp-logs-dev
}

Any expression can go inside — variables, resource attributes, function calls, and more.

tags = {
  Name       = "web-${count.index}"          # web-0, web-1, ...
  CreatedAt  = "${formatdate("YYYY-MM-DD", timestamp())}"
  BucketName = "${lower(replace(var.project, "_", "-"))}"
}

However, when the entire argument value is a single expression, the convention is to write it directly without interpolation. The old "${var.x}" style is now discouraged.

# Old style
region = "${var.region}"

# Current style (recommended)
region = var.region

Only use ${} when embedding values inside a string.

Heredoc — Multi-line Strings

A syntax that prevents escape hell when writing long strings. Used for multi-line data like IAM policies and user data scripts.

resource "aws_iam_policy" "read_logs" {
  name   = "read-logs"
  policy = <<-EOT
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": ["s3:GetObject"],
          "Resource": "arn:aws:s3:::${var.bucket_name}/*"
        }
      ]
    }
  EOT
}

Starts with <<-EOT and closes with EOT. The marker (EOT) can be whatever you want (EOF, END, POLICY…). The <<- form strips common leading indentation for better readability (<< alone includes indentation in the string).

You can embed JSON policies this way, but the jsonencode() function is safer.

resource "aws_iam_policy" "read_logs" {
  name = "read-logs"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:GetObject"]
      Resource = "arn:aws:s3:::${var.bucket_name}/*"
    }]
  })
}

This approach catches JSON syntax errors (like missing commas) at compile time, so it’s recommended.

Conditionals — Ternary Operator

HCL has no if statement. Instead, it has the ternary operator found in most languages.

<condition> ? <value if true> : <value if false>

Practical usage looks like this.

variable "environment" {
  default = "dev"
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"

  tags = {
    Backup = var.environment == "prod" ? "daily" : "none"
  }
}

Frequently used for per-environment branching like “large instances for production, small ones for everything else.” To chain multiple conditions, combine with &&, ||, and !.

high_availability = var.environment == "prod" && var.region != "ap-northeast-2"

If conditionals get too complex, using locals for intermediate variables improves readability.

locals {
  is_prod    = var.environment == "prod"
  use_large  = local.is_prod || var.force_large
}

for Expressions — List/Map Transformation

HCL’s for expression is almost identical to Python’s list comprehension. Used when transforming one collection into another.

variable "users" {
  default = ["alice", "bob", "carol"]
}

locals {
  # List -> List
  upper_users = [for u in var.users : upper(u)]
  # ["ALICE", "BOB", "CAROL"]

  # List -> Map
  user_map = {for u in var.users : u => "${u}@example.com"}
  # {alice = "alice@example.com", bob = ...}

  # Conditional filter
  short_names = [for u in var.users : u if length(u) < 5]
  # ["alice", "bob"]
}

When iterating over a map, you receive (key, value) together.

variable "tags" {
  default = {
    env  = "dev"
    team = "platform"
  }
}

locals {
  formatted = [for k, v in var.tags : "${k}=${v}"]
  # ["env=dev", "team=platform"]
}

When creating multiple resources, combining for expressions with for_each keeps things clean. Resource iteration is revisited in Part 6.

Splat Operator — Extracting Attributes at Once

Used when you want to extract attributes from multiple resources created with count or for_each all at once. * is the splat operator.

resource "aws_instance" "web" {
  count         = 3
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
}

# Use splat to get all instances' public_ip as a list
output "all_public_ips" {
  value = aws_instance.web[*].public_ip
  # ["3.1.1.1", "3.2.2.2", "3.3.3.3"]
}

The same expression using a for expression would be:

value = [for i in aws_instance.web : i.public_ip]

Splat is shorter. For simply extracting the same attribute, splat wins; when processing is needed, for is more appropriate.

Built-in Functions — Frequently Used in Practice

Terraform has over 100 built-in functions. You don’t need to memorize them all — just know “these exist.” Here are a few commonly used ones.

String Functions

lower("Hello")                 # "hello"
upper("hello")                 # "HELLO"
title("hello world")           # "Hello World"
trim("  hello  ", " ")         # "hello"
replace("a-b-c", "-", "_")     # "a_b_c"
format("hello, %s!", "world")  # "hello, world!"

format uses printf-style formatting, so it’s handy to remember.

Collection Functions

length(["a", "b", "c"])        # 3
length("hello")                # 5
concat(["a"], ["b"], ["c"])    # ["a", "b", "c"]
merge({a=1}, {b=2})            # {a=1, b=2}
lookup({a=1}, "a", 0)          # 1 (default 0)
lookup({a=1}, "b", 0)          # 0
contains(["a","b"], "a")       # true
keys({a=1, b=2})               # ["a", "b"]
values({a=1, b=2})             # [1, 2]

merge is frequently used for combining maps. A typical pattern is combining base tags with resource-specific tags.

locals {
  common_tags = {
    ManagedBy   = "terraform"
    Environment = var.environment
  }
}

resource "aws_instance" "web" {
  # ...
  tags = merge(local.common_tags, { Role = "web" })
}

Encoding/Decoding

jsonencode({ a = 1 })          # "{\"a\":1}"
jsondecode("{\"a\":1}")        # {a = 1}
yamlencode({ a = 1 })          # "a: 1\n"
base64encode("hello")          # "aGVsbG8="
filebase64("cert.pem")         # Read file and base64 encode

Frequently used for EC2 user data and similar cases that require Base64 encoding.

Date/Time

timestamp()                            # "2026-04-20T04:30:00Z"
formatdate("YYYY-MM-DD", timestamp())  # "2026-04-20"

An important caveat: timestamp() returns a different value on every apply, so using it directly in resource arguments triggers a change detection every time. When using it in tags, either pair it with lifecycle { ignore_changes = [tags["LastUpdated"]] } or avoid using it altogether.

File Reading

file("init.sh")           # Read a text file as-is
templatefile("init.sh.tpl", { name = "web" })  # Template rendering

templatefile renders interpolation like ${name} within the file. Useful when separating user data scripts into files.

# init.sh.tpl
#!/bin/bash
echo "Hello, ${name}!" > /tmp/hello.txt
resource "aws_instance" "web" {
  # ...
  user_data = templatefile("${path.module}/init.sh.tpl", {
    name = "terraform"
  })
}

The Flow of Expressions and Values

Here’s a diagram showing how the syntax covered so far combines and where everything fits.

flowchart TB
    V[variable<br/>External input] --> EX[Expressions]
    L[locals<br/>Intermediate calculations] --> EX
    D[data source<br/>Query results] --> EX
    R2[Other resources<br/>References] --> EX
    EX -->|String interpolation<br/>Conditionals<br/>for<br/>Functions| VAL[Final value]
    VAL --> R[Resource arguments]
    VAL --> O[output]

Most HCL code follows the structure of “processing input values through expressions and feeding them into resource arguments.” The sources of input are variables, locals, data sources, and attributes of other resources. Once this flow becomes familiar, you can read any project’s Terraform code and roughly follow along.

Common Mistakes

A few mistakes that are easy to miss when just following the language description.

These mistakes stick in memory once you make them. Building a habit of carefully reading error messages is the most helpful thing in the long run.

What’s Next

The syntax covered in this part forms the backbone of HCL. The next part is about the mechanisms that enable “reuse” on top of this syntax. We’ll inject values from outside with variables (variable), extract values with outputs (output), and organize intermediate calculations with locals (locals). Practical topics like the type system, validation, and .tfvars precedence will also be covered.


-> Part 4: Variables and Outputs


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform Part 2 — Installation and First Deploy
Next Post
Terraform Part 4 — Variables and Outputs