TerraformIV · HCL: The Terraform Configuration LanguageHCL
HCL Fundamentals
What you'll learn
- Read and write the basic HCL block structure
- Identify the value types: string, number, bool, list, map, set, object, tuple, null
- Use references to read attributes from resources and variables
- Recognise the expressions Terraform supports and which to avoid
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
HCL is the language the configuration is written in. The course teaches enough HCL to read and write Terraform confidently. It deliberately does not teach every HCL feature — most production configurations use a small subset of the language.
The block structure
HCL has exactly three block types:
# Top-level configuration
terraform {
required_version = ">= 1.9.0"
}
# Provider configuration
provider "aws" {
region = "us-east-1"
}
# Resource declaration
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
}
# Data source
data "aws_ami" "ubuntu" {
most_recent = true
# ...
}
# Module call
module "network" {
source = "./modules/network"
}
# Variable
variable "environment" {
type = string
default = "dev"
}
# Output
output "instance_id" {
value = aws_instance.web.id
}
# Local
locals {
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
The blocks are not interchangeable. A resource block declares a
resource; a data block reads from the provider; a variable
block declares an input. The block type is structural; the
provider documentation is the authority for what arguments are
valid inside each block.
Argument value types
HCL has nine value types:
| Type | Example | Notes |
|---|---|---|
string | "hello", "${var.environment}" | Double-quoted; supports interpolation |
number | 42, 3.14 | No thousand separators |
bool | true, false | Not 1 / 0 |
list(...) | ["a", "b", "c"] | Ordered, indexable |
map(...) | {key = "value"} | Key-value pairs |
set | toset(["a", "b"]) | Unordered, no duplicates |
object | ({key = "value"}) | Structured record |
tuple | ["a", 1, true] | Heterogeneous list |
null | null | The absence of a value |
Two of these deserve particular attention:
list vs set — a list is ordered, a set is unordered. The
state records the order of a list (so the same code always
produces the same real-world resources); for a set, order is
unstable. Use for_each over a set, not count, to avoid
sensitive-to-order bugs.
object vs map — an object has typed fields; a map has
homogeneous values. Use object when the structure matters
(“a name and a port, not a name and a port and a tag and a
subnet”). Use map when the structure is uniform (“all values
are strings”).
References
A reference is a way to read an attribute from another object:
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
subnet_id = aws_subnet.public.id # reference to another resource
vpc_security_group_ids = [
aws_security_group.web.id,
]
}
output "private_ip" {
value = aws_instance.web.private_ip # attribute of the resource
}
output "ami_id" {
value = data.aws_ami.ubuntu.id # attribute of a data source
}
output "db_endpoint" {
value = module.database.endpoint # output of a module
}
The reference aws_instance.web.id is the resource address
of the aws_instance.web resource, followed by the attribute
name. The full resource address is the basis for terraform state
commands and the resource graph.
A reference creates an implicit dependency. Terraform will
not create the aws_instance.web until the aws_subnet.public
is created. The dependency graph is built from the references.
String interpolation
name = "web-${var.environment}"
The ${...} syntax is template interpolation. The result is a
string. The expression inside ${...} can be any valid HCL
expression.
subnet_cidr = "10.0.${var.az_number}.0/24"
Moustache-style references ({{...}}) are not supported in HCL.
Conditionals
instance_type = var.environment == "prod" ? "t3.large" : "t3.small"
The conditional expression condition ? value_if_true : value_if_false
is a one-line if. Use it sparingly — deeply nested conditionals
are hard to read.
For expressions
# List comprehension
upper_names = [for n in var.names : upper(n)]
# Map comprehension
name_by_id = { for n in var.names : n.id => n.name }
# Filter
dev_names = [for n in var.names : n if n.environment == "dev"]
For expressions are powerful. They are also a frequent source of hard-to-read code. The course recommends:
- Use comprehensions for simple transformations.
- Use a
forloop in code generation (a separate script) for complex transformations. - If the comprehension is more than one line, prefer a
forloop or a module.
Functions
HCL has a large standard library of functions:
name = upper(var.name)
endpoint = "https://${var.subdomain}.example.com"
ports = [for p in var.ports : tonumber(p)]
The full list is in the Terraform documentation. A few worth knowing:
lookup(map, key, default)— look up a key in a map with a default.merge(map1, map2, ...)— merge maps; later maps take precedence.concat(list1, list2, ...)— concatenate lists.flatten(list)— flatten nested lists.format("...", args)— format a string.jsonencode(value)/jsondecode(string)— JSON serialisation.try(expr, default)— try an expression and return a default on failure.
Conditional resource creation
resource "aws_instance" "web" {
count = var.create_instance ? 1 : 0
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
}
count and for_each are not value types — they are
meta-arguments that control how many instances of a resource
are created. The course has a dedicated lesson on count and
for_each.
The style guide
The official Terraform style guide is short:
- Two-space indentation.
- One blank line between blocks.
- Resource and variable names in snake_case.
- Resource names should be descriptive (“web” instead of “i”).
- Argument order should be consistent.
# Good
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
tags = {
Name = "web-${var.environment}"
}
}
# Less good
resource "aws_instance" "web" {
ami="ami-0e1bed4f"
instance_type = "t3.medium"
tags = {
Name = "web-${var.environment}"
}
}
terraform fmt enforces most of the style guide. Run it on every
commit.
What to avoid
A few patterns that should be avoided in production:
Magic strings. A literal string in a configuration that is used in multiple places. Replace with a variable.
# Avoid
resource "aws_instance" "web" {
instance_type = "t3.medium"
tags = {
Environment = "production"
}
}
# Prefer
variable "instance_type" {
type = string
default = "t3.medium"
}
variable "environment" {
type = string
}
resource "aws_instance" "web" {
instance_type = var.instance_type
tags = {
Environment = var.environment
}
}
Deeply nested expressions. A one-liner that reads
flatten([for m in var.modules : keys(m)]) is hard to read.
Prefer a local or a for loop.
Clever use of lookup and coalesce. A default value that
hides a missing key is a debugging nightmare. Let the plan fail
if the configuration is wrong.
Recursion. Terraform does not allow recursive modules. The reason is that the resource graph must be a DAG. If you find yourself needing recursion, you are probably modelling the wrong thing.
What comes next
The next lesson is expressions in depth: most of what you read in a Terraform configuration is an expression. The expressions lesson is the second half of the HCL material.
Knowledge check · 7 questions
Q1. Which is a valid HCL block type?
Q2. How do you reference an attribute of a resource?
Q3. In HCL, `null` and `undefined` are the same value.
Q4. What is the role of `count` in a resource block?
Q5. Which of the following are valid HCL value types? (Select all that apply.)
Q6. What is the role of the `for` expression in HCL?
Q7. A configuration has `[for s in var.list : upper(s)]` in an output. What does this do?
Passing score: 75%. Answers are checked in this browser.