Skip to main content
RunBook Academy

TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform

HCL Blocks, Arguments, and the Configuration File

Foundation⏱ ~12 minbash

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

Not yet marked complete on this device.

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:

  1. There is no top-level statement like apply() or main(). The whole file is declarative.
  2. Block order does not matter. Terraform reads all blocks, then builds a dependency graph from the references between them.
  3. 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 kindHeader slotsExample
resource<TYPE> <NAME>resource "aws_instance" "web" {
data<TYPE> <NAME>data "aws_ami" "debian_12" {
module<NAME> onlymodule "network" {
variable<NAME> onlyvariable "region" {
output<NAME> onlyoutput "vpc_id" {
localsnonelocals {
provider<NAME> onlyprovider "aws" {
terraformnoneterraform {

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"
  }
}
  • provider configures a single plugin. Most attributes differ per provider; region, assume_role, skip_credentials_validation, and similar are AWS-specific.
  • terraform declares 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:

  1. 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.
  2. 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.
  3. Top-level block that is not one of the eight. Common variants: loop { ... }, if { ... }, function, class. Symptom: Error: Unsupported block type. Recovery: refactor into resource/data/module/variable/output/locals, or into one of the children of the terraform block.
  4. Nested block at file scope. Writing tags { Name = "web-1" } at the top level. Symptom: Error: Unsupported block type. Recovery: move it inside the relevant resource body.
  5. Variable declared twice. Two variable "region" { ... } blocks in one module. Symptom: silent override (the second wins), no error. Recovery: declare once, derive specialised values in locals. Detect this in CI with terraform console and a for ... in local.variables check.
  6. tags = {} against a provider that expects tags {} (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:

  1. terraform validate to isolate grammar from the plan phase.
  2. terraform fmt -check -recursive -diff to confirm files are well-formed before deeper inspection.
  3. grep -rn '^resource "<type>" "<name>"' . to find duplicates across files.
  4. terraform console to test single references in isolation: > aws_instance.web.id.
  5. Fix labels or split the block.
  6. Re-run terraform plan until the diff matches the intended change.

References

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

  1. Q1. Which set of three block kinds is the minimal declarative core of any Terraform root module?

  2. Q2. Which is a correctly headed Terraform block?

  3. Q3. Tags written as `tags = { Name = "web-1" }` and as `tags { Name = "web-1" }` are always interchangeable in Terraform.

  4. Q4. Where is a `variable` block allowed to appear?

  5. Q5. Which three of the following are valid top-level block kinds in Terraform 1.9?

  6. Q6. Which block declares the state backend and the required Terraform and provider versions?

  7. 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.