Terraform HCL Explained: Blocks, Arguments, Expressions, and Common Patterns
July 7, 2026 · 8 min read
Terraform HCL Explained: Blocks, Arguments, Expressions, and Common Patterns
HashiCorp Configuration Language (HCL) is the declarative syntax that sits at the heart of every Terraform project, and understanding it deeply separates engineers who fight their infrastructure code from those who move fast and confidently. Unlike JSON or YAML, HCL was designed specifically for human-readable infrastructure definitions — it reads naturally while remaining machine-parseable. This guide walks through every core concept: block types, argument syntax, expression evaluation, and the patterns that appear in nearly every real-world Terraform codebase. If you work with configuration formats beyond HCL, tools like the YAML to HCL Converter and JSON to HCL Converter can accelerate migrations and cross-format work.
HCL File Structure and the Three Block Categories
Every .tf file is composed of blocks — delimited by a type label, optional instance labels, and a body wrapped in curly braces. Terraform recognizes roughly a dozen top-level block types, but they fall into three practical categories: configuration blocks (like terraform and provider), resource and data blocks (what you provision and read), and abstraction blocks (like variable, output, locals, and module).
# Configuration block — exactly one per root module
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# Provider block — zero or more per provider
provider "aws" {
region = "us-east-1"
}
# Resource block — two labels: type and name
resource "aws_s3_bucket" "assets" {
bucket = "my-app-assets-prod"
}
Files are processed together as a single logical document — Terraform merges all .tf files in a directory before evaluation, which means declaration order within a directory does not matter. Variable references are resolved via a dependency graph, not top-to-bottom execution. A root module typically separates concerns across main.tf, variables.tf, outputs.tf, and providers.tf, though Terraform itself enforces no naming convention.
Arguments, Attributes, and HCL Data Types
An argument assigns a value to a named slot inside a block body. An attribute is a value that a resource or data source exposes after it exists — you reference it with dot notation. The distinction matters because arguments are inputs you provide; attributes are outputs you read back.
HCL supports six primitive types and three collection types:
| Type | Example |
|---|---|
string |
"us-east-1" |
number |
8080 |
bool |
true |
list(T) |
["a", "b", "c"] |
set(T) |
toset(["a", "b"]) |
map(T) |
{ key = "val" } |
object({…}) |
structured shape |
tuple([T…]) |
fixed-length sequence |
resource "aws_security_group" "web" {
name = "web-sg"
description = "Allow HTTPS inbound"
vpc_id = aws_vpc.main.id # <-- attribute reference, not an argument
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # list(string)
}
}
Terraform performs implicit type coercion in many places — a number is accepted where a string is expected and vice versa — but relying on this in module interfaces makes code harder to review. Declare explicit types in every variable block to make contracts visible.
Expressions, Interpolation, and Template Syntax
HCL expressions range from simple literal values to complex multi-step computations. The most common form is reference interpolation: "${var.region}-prod" embeds a variable value inside a string. As of Terraform 0.12, template syntax is only required when mixing static text with references; a standalone reference like var.region needs no ${} wrapper.
locals {
env_prefix = "${var.project}-${var.environment}"
bucket_name = "${local.env_prefix}-assets"
is_prod = var.environment == "prod"
replica_count = local.is_prod ? 3 : 1
}
Terraform ships around 120 built-in functions organized into categories — string, numeric, collection, filesystem, encoding, date/time, hash, IP network, and type conversion. Several appear in virtually every codebase:
# String
upper(var.name) # "MYAPP"
replace(var.name, "-", "_")
# Collection
length(var.subnet_ids) # 3
flatten([["a", "b"], ["c"]]) # ["a", "b", "c"]
merge(local.base_tags, var.extra_tags)
# Encoding
jsonencode({ port = 8080 }) # "{\"port\":8080}"
base64encode("secret")
# Type conversion
toset(var.availability_zones)
The try() function — available since Terraform 0.13 — suppresses errors from failed attribute lookups and returns a fallback, which is useful when accessing optional nested objects that may not exist on older resource versions.
Variables, Outputs, and Type Constraints
Variables are the public API surface of a module. Every variable should declare a type, a description, and, where appropriate, validation blocks. A missing description is a code smell that will cost future-you twenty minutes of reading provider docs.
variable "instance_type" {
type = string
description = "EC2 instance type for the application tier."
default = "t3.medium"
validation {
condition = can(regex("^(t3|m5|c5)\\.", var.instance_type))
error_message = "Must be a t3, m5, or c5 family instance."
}
}
variable "tags" {
type = map(string)
default = {}
}
Outputs expose values after terraform apply — either for human inspection or for consumption by a calling module via module.<name>.<output>. Mark sensitive outputs with sensitive = true; Terraform will redact them in plan output and state display, though the value is still stored in state.
output "alb_dns_name" {
description = "Public DNS name of the load balancer."
value = aws_lb.main.dns_name
}
output "db_password" {
value = random_password.db.result
sensitive = true
}
Passing structured values between modules using object({…}) types gives you compile-time shape checking and dramatically reduces the silent mismatches that cause plan-time surprises.
Locals and Data Sources
locals blocks compute intermediate values that would otherwise be repeated. Think of them as named constants or derived expressions — they reduce repetition and make intent explicit. Unlike variables, locals are not settable from outside the module.
locals {
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
azs = slice(data.aws_availability_zones.available.names, 0, 3)
private_cidrs = [for i, _ in local.azs : cidrsubnet(var.vpc_cidr, 8, i)]
public_cidrs = [for i, _ in local.azs : cidrsubnet(var.vpc_cidr, 8, i + 10)]
}
data blocks read existing infrastructure or external information without managing it. They are evaluated during the plan phase, so any attribute they expose is available in resource arguments.
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
Data sources that depend on resources which do not yet exist will fail plan — a common pitfall when referencing a VPC ID from a data "aws_subnet_ids" block before the VPC resource has been created in the same run. Use depends_on sparingly to encode these ordering requirements.
Loops, Conditionals, and Dynamic Blocks
Three meta-arguments handle iteration and conditional creation: count, for_each, and dynamic. Each solves a different problem, and mixing them incorrectly is the most common source of unexpected diffs.
count creates N copies of a resource, indexed by integer. Avoid it for anything but simple on/off toggles (count = var.create_bucket ? 1 : 0), because removing an element from the middle of a counted list forces Terraform to destroy and recreate everything after that index.
for_each iterates over a map or set, keying each instance by the map key or set value. Reordering or removing elements destroys only the affected instances:
resource "aws_iam_user" "service_accounts" {
for_each = toset(var.service_account_names)
name = each.value
}
resource "aws_subnet" "private" {
for_each = zipmap(local.azs, local.private_cidrs)
vpc_id = aws_vpc.main.id
availability_zone = each.key
cidr_block = each.value
tags = merge(local.common_tags, { Name = "private-${each.key}" })
}
dynamic blocks generate repeated nested blocks — like multiple ingress rules in a security group — from a collection:
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
}
For-expressions ([for x in list : transform(x) if condition(x)]) produce new lists or maps inline and are the right choice when you need to reshape a collection rather than create resources from it.
Module Patterns and Code Reuse
A Terraform module is simply a directory of .tf files. The root module is the directory where you run terraform apply; child modules are referenced via module blocks. Well-factored modules accept inputs via variables, expose outputs, and encapsulate complexity behind a stable interface.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.2"
name = local.env_prefix
cidr = var.vpc_cidr
azs = local.azs
private_subnets = local.private_cidrs
public_subnets = local.public_cidrs
enable_nat_gateway = true
single_nat_gateway = !local.is_prod
tags = local.common_tags
}
The Terraform Registry hosts over 15,000 community modules, but evaluate them carefully — check the issue count, last commit date, and whether they pin provider versions. Internal modules should live in a versioned VCS path or a private registry, not as relative paths shared across projects, because source = "../../modules/vpc" breaks when the relative layout changes.
Module composition — calling modules from modules — is powerful but adds depth that complicates terraform state operations. Keep call stacks to two or three levels deep. When infrastructure grows beyond what a single state file can hold cleanly, split it by lifecycle boundary (network, data, compute) and connect layers via terraform_remote_state data sources or output-passing conventions. Tools like the Docker Compose Generator and Nginx Config Generator are useful companions when the services you provision with Terraform also need container or proxy configuration to match.
Conclusion
HCL rewards the time you invest in learning its conventions. The block-and-argument model keeps large configurations readable, the type system catches interface mistakes before a real cloud API is called, and the expression language — functions, for-expressions, dynamic blocks — provides enough power to eliminate almost all duplication without reaching for a general-purpose language. The practical path forward: pin provider and module versions, type every variable explicitly, prefer for_each over count for multi-instance resources, keep modules shallow, and let terraform validate and terraform plan be your first line of review before any apply. With these fundamentals in place, HCL becomes an asset rather than an obstacle in infrastructure delivery.
Try these free tools
Free & private — all tools run in your browser, nothing uploaded.