HCL Syntax
HashiCorp Configuration Language (HCL) is the language you use to describe infrastructure in Terraform and OpenTofu. It is purpose-built to be both human-readable and machine-friendly, sitting somewhere between a config format like JSON and a full programming language. Understanding its three core building blocks — blocks, arguments, and identifiers — is the foundation for everything else you write, because every resource, variable, and module declaration is just a composition of these primitives.
The structure of an HCL file
A Terraform configuration is a collection of .tf files written in HCL2, the second generation of the language introduced in Terraform 0.12 and now standard across all modern versions (1.5+) and OpenTofu. Terraform loads every .tf file in a directory and merges them into a single configuration, so file boundaries are purely organizational.
The grammar is small. The entire language is expressed through two constructs:
- Blocks — containers that group related configuration and have a type, optional labels, and a body delimited by
{ }. - Arguments —
name = valueassignments that set a particular setting.
Everything you write is one of these two things, often nested.
Blocks
A block has a type, zero or more labels, and a body. The type tells Terraform what kind of object you are declaring; the labels name or qualify it; the body holds the arguments and any nested blocks.
resource "aws_instance" "web" {
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}
In this example:
resourceis the block type."aws_instance"and"web"are labels — the first names the resource type, the second is your local name for this instance.- Everything inside
{ }is the block body.
Different block types expect different numbers of labels. The table below summarizes the most common top-level blocks.
| Block type | Labels | Purpose |
|---|---|---|
resource | type, name | Manages a piece of infrastructure |
data | type, name | Reads existing infrastructure |
variable | name | Declares an input variable |
output | name | Exposes a value after apply |
provider | name | Configures a provider |
module | name | Calls a reusable module |
terraform | (none) | Configures Terraform itself |
Blocks can also be nested inside other blocks, as with the tags map above or the ingress blocks within a security group.
Arguments
An argument assigns a value to a name using name = value. The value can be a literal, a reference to another object, or any expression that evaluates to a value.
variable "environment" {
type = string
default = "production"
}
resource "aws_s3_bucket" "logs" {
bucket = "devcraftly-${var.environment}-logs"
}
Here type, default, and bucket are arguments. The bucket value uses string interpolation to reference the environment variable. The whitespace and alignment of = are stylistic — Terraform ignores them, and terraform fmt will align them for you.
Identifiers
Identifiers name blocks, arguments, variables, and outputs. A valid identifier begins with a letter or underscore and may contain letters, digits, underscores, and hyphens. By convention, Terraform uses lowercase snake_case.
output "instance_public_ip" {
value = aws_instance.web.public_ip
}
The reference aws_instance.web.public_ip is itself a chain of identifiers: resource type, resource name, and attribute.
Tip: Resource names live in a per-type namespace, so
aws_instance.webandaws_s3_bucket.webcan coexist. Use descriptive names — they appear in plan output, state, and error messages.
Comments
HCL supports three comment styles. Single-line comments are the most common.
# This is the default and preferred single-line comment
// This double-slash form is also valid for single lines
/*
This is a block comment
spanning multiple lines.
*/
resource "aws_instance" "web" {
instance_type = "t3.micro" # inline comments work too
}
terraform fmt rewrites // comments to #, so prefer # to avoid churn in formatting diffs.
Declarative and order-independent
HCL is declarative: you describe the desired end state, not the steps to reach it. Critically, the order of blocks in your files does not matter. Terraform builds a dependency graph from references between objects and determines the correct creation order automatically.
# Defined first, but created AFTER the VPC it depends on
resource "aws_subnet" "app" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
Even though the subnet appears before the VPC, the aws_subnet.app.vpc_id = aws_vpc.main.id reference tells Terraform the VPC must exist first.
Output:
Terraform will perform the following actions:
# aws_subnet.app will be created
+ resource "aws_subnet" "app" {
+ cidr_block = "10.0.1.0/24"
+ id = (known after apply)
+ vpc_id = (known after apply)
}
# aws_vpc.main will be created
+ resource "aws_vpc" "main" {
+ cidr_block = "10.0.0.0/16"
+ id = (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.
The (known after apply) markers reflect values that depend on the VPC, confirming Terraform resolved the dependency despite the source ordering.
Annotated example
The following snippet ties the pieces together: a terraform settings block, a provider, a variable, a resource with a nested block, and an output.
terraform { # block type, no labels
required_version = ">= 1.5" # argument
}
provider "aws" { # block type + 1 label
region = "us-east-1" # argument
}
variable "instance_count" { # input declaration
type = number
default = 1
}
resource "aws_instance" "app" { # block type + 2 labels
count = var.instance_count # references the variable
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
root_block_device { # nested block
volume_size = 20
}
}
output "app_ids" { # output declaration
value = aws_instance.app[*].id # expression
}
This is valid for both Terraform 1.5+ and OpenTofu, which share the HCL2 grammar.
Best Practices
- Run
terraform fmt(ortofu fmt) before committing to keep indentation, alignment, and comment style consistent. - Prefer
#for comments sincefmtnormalizes//to#. - Use descriptive
snake_caseidentifiers — they surface in plan output and state. - Split configuration across files by concern (
main.tf,variables.tf,outputs.tf) rather than worrying about block order, which Terraform ignores. - Let references drive dependencies instead of reordering blocks or reaching for
depends_onunnecessarily. - Always pin
required_versionin theterraformblock to avoid surprises across CLI upgrades.