TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform
HCL Blocks, Arguments, and the Configuration File
What you'll learn
- Name the eight top-level block kinds Terraform recognises and state the role of each
- Read a block header `<kind> "<type>" "<name>"` and apply the label conventions
- Distinguish a block argument (an assignment) from a nested block (a structural group)
- Choose the correct block kind for declaring, querying, parameterising, and publishing values
- Spot the three most common block-grammar mistakes before `terraform plan`
Prerequisites
None — start here.
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-13
Everything Terraform does is expressed by combining a small, closed set of blocks. Production configurations are not particularly long, but they are precise: an extra comma, a mislabelled header, or a misplaced tags turns a five-minute plan into a thirty-minute incident. The lesson is the grammar that prevents those incidents.
Mental model: a configuration is a flat list of blocks
A Terraform configuration file has no surrounding root element. It is a sequence of top-level blocks, one after another. Every block has the same shape:
+------------------------------+
| <KIND> "<TYPE>" "<NAME>" | <- the header
+------------------------------+
| |
| argument = expression | <- arguments (assignments)
| argument = expression |
| |
| nested_block "label" { | <- nested blocks (structural)
| argument = expression |
| } |
| |
+------------------------------+
Three things follow directly from this picture:
- There is no top-level statement like
apply()ormain(). The whole file is declarative. - Block order does not matter. Terraform reads all blocks, then builds a dependency graph from the references between them.
- Whitespace and comments do not change meaning. Indentation matters to humans, not to the parser.
The block header
The header identifies the block. It carries up to three labelled slots:
<BLOCK_KIND> "<TYPE_LABEL>" "<NAME_LABEL>" {
# body
}
Different block kinds consume different slots:
| Block kind | Header slots | Example |
|---|---|---|
resource | <TYPE> <NAME> | resource "aws_instance" "web" { |
data | <TYPE> <NAME> | data "aws_ami" "debian_12" { |
module | <NAME> only | module "network" { |
variable | <NAME> only | variable "region" { |
output | <NAME> only | output "vpc_id" { |
locals | none | locals { |
provider | <NAME> only | provider "aws" { |
terraform | none | terraform { |
The labels are case-sensitive. aws_instance and AWS_instance are different resource types. The convention everywhere is lower_snake_case: providers use it for type names; you use it for your own local labels.
The eight top-level block kinds
A configuration can contain at most these top-level block kinds. There is no for, no if, no function. Anything you want to do is composed from this list.
resource — declare infrastructure
resource "aws_instance" "web" {
ami = "ami-0c1b8b2a3f4e5d6c7"
instance_type = "t3.small"
tags = {
Name = "web-1"
Owner = "platform@example.com"
}
}
The two labels are (resource_type, resource_name). Together they form the resource address aws_instance.web, which is how other blocks reference this object. The address is unique within a configuration; declaring it twice is a hard error.
data — query existing infrastructure
data "aws_ami" "debian_12" {
most_recent = true
owners = ["136693071363"] # Debian project
filter {
name = "name"
values = ["debian-12-amd64-*"]
}
}
A data block runs a read-only query against the provider during terraform plan and exposes the result as data.aws_ami.debian_12. The address shape is deliberately the same as for resource so the rest of the configuration cannot tell the two apart at the reference site.
variable and output — the configuration boundary
variable "region" {
type = string
default = "eu-west-1"
description = "AWS region used by every resource in this module."
}
output "vpc_id" {
value = aws_vpc.main.id
description = "The ID of the main VPC."
sensitive = false
}
A variable is an input. The label is the variable’s name; the value is supplied through -var, -var-file, environment variables (TF_VAR_region), or a terraform.tfvars file. An output is a published return value, visible after apply and to parent modules.
module — call a packaged configuration
module "network" {
source = "./modules/network"
cidr_block = "10.0.0.0/16"
region = var.region
}
The source attribute accepts a local path, a Git URL, a Terraform Registry address, or an S3/GCS object. The label is the local name of this instance (module.network).
locals — named, computed values
locals {
common_tags = {
Owner = "platform@example.com"
Environment = var.environment
ManagedBy = "terraform"
}
}
locals is the only block without labels. Its body is a flat list of name = expression pairs. Locals cannot be exported; they are scoped to the file/module in which they are declared.
provider and terraform — configuration of the tool
provider "aws" {
region = "eu-west-1"
assume_role {
role_arn = "arn:aws:iam::123456789012:role/TerraformDeploy"
}
}
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "acme-tfstate"
key = "prod/terraform.tfstate"
region = "eu-west-1"
}
}
providerconfigures a single plugin. Most attributes differ per provider;region,assume_role,skip_credentials_validation, and similar are AWS-specific.terraformdeclares three things: the tool version, the provider sources and versions, and (optionally) the state backend.
Arguments versus nested blocks
Inside a block body you can write two kinds of children, and the distinction is important.
Arguments are assignments
instance_type = "t3.small" # plain string argument
count = var.replicas # expression argument
An argument is name = expression. The expression can be a literal ("t3.small"), a reference (var.replicas), a function call (upper(var.env)), a conditional (var.prod ? "m7i.large" : "t3.small"), or one of a few operators. Arguments are flat: one name, one value.
Nested blocks have their own header
resource "aws_instance" "web" {
ami = "ami-0c1b8b2a3f4e5d6c7"
instance_type = "t3.small"
root_block_device { # nested block
volume_size = 20
encrypted = true
}
}
A nested block uses the same <block_name> [labels] { ... } shape as a top-level block. The provider schema decides which nested blocks the resource accepts and what their arguments are.
Anatomy of a real configuration file
A production file is ordered by convention, not by requirement:
terraform { ... backend, version pins ... }
provider "..." { ... }
locals { ... }
data "..." "..." { ... }
variable "..." { ... }
resource "..." "..." { ... }
output "..." { ... }
terraform fmt re-orders blocks for you under most settings; terraform validate does not care. The convention exists because humans reviewing a 1,000-line file read it faster when the layout is predictable.
Production failure modes
These are the block-grammar mistakes that recur in code review:
- Duplicate resource address. Two
resource "aws_instance" "web"blocks in the same module. Symptom:Error: Duplicate resource configuration. Recovery: rename one ("web" -> "web_blue"), or split it into a separate module if both are genuinely needed. - Single-label resource header.
resource "aws_instance.web" { ... }. Symptom:Error: The resource type name must be followed by a name. Recovery: split into two string literals. - Top-level block that is not one of the eight. Common variants:
loop { ... },if { ... },function,class. Symptom:Error: Unsupported block type. Recovery: refactor intoresource/data/module/variable/output/locals, or into one of the children of theterraformblock. - Nested block at file scope. Writing
tags { Name = "web-1" }at the top level. Symptom:Error: Unsupported block type. Recovery: move it inside the relevantresourcebody. - Variable declared twice. Two
variable "region" { ... }blocks in one module. Symptom: silent override (the second wins), no error. Recovery: declare once, derive specialised values inlocals. Detect this in CI withterraform consoleand afor ... in local.variablescheck. tags = {}against a provider that expectstags {}(or vice versa). Symptom:Error: Invalid argument. Recovery: read the provider docs for the resource, or check the schema JSON above.
Recovery procedure
When a block-grammar error lands during a change window:
terraform validateto isolate grammar from the plan phase.terraform fmt -check -recursive -diffto confirm files are well-formed before deeper inspection.grep -rn '^resource "<type>" "<name>"' .to find duplicates across files.terraform consoleto test single references in isolation:> aws_instance.web.id.- Fix labels or split the block.
- Re-run
terraform planuntil the diff matches the intended change.
References
- HashiCorp, “Configuration Language” — https://developer.hashicorp.com/terraform/language
- HashiCorp, “Resources” — https://developer.hashicorp.com/terraform/language/resources
- HashiCorp, “The
terraformblock” — https://developer.hashicorp.com/terraform/language/terraform
What comes next
The next lesson is HCL Types and Values: what the expressions inside those arguments are allowed to be, and what Terraform decides about them when a list(string) meets a list(any).
Verification
terraform fmt -check -recursive
terraform validate
terraform plan -refresh-only
Expected output for a sound configuration:
$ terraform validate
Success! The configuration is valid.
A non-empty result from terraform fmt -check -recursive means at least one file is not in canonical formatting. Run terraform fmt to fix it. A non-empty diff from terraform plan -refresh-only means drift has been detected (some resource attributes changed in the real world); that is not a grammar problem but the next lesson’s territory.
Knowledge check · 7 questions
Q1. Which set of three block kinds is the minimal declarative core of any Terraform root module?
Q2. Which is a correctly headed Terraform block?
Q3. Tags written as `tags = { Name = "web-1" }` and as `tags { Name = "web-1" }` are always interchangeable in Terraform.
Q4. Where is a `variable` block allowed to appear?
Q5. Which three of the following are valid top-level block kinds in Terraform 1.9?
Q6. Which block declares the state backend and the required Terraform and provider versions?
Q7. Two files in a module both declare `resource "aws_instance" "web" { ... }`. What happens on the next `terraform plan`?
Passing score: 75%. Answers are checked in this browser.