Skip to content
Infrastructure as Code iac hcl 5 min read

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 { }.
  • Argumentsname = value assignments 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:

  • resource is 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 typeLabelsPurpose
resourcetype, nameManages a piece of infrastructure
datatype, nameReads existing infrastructure
variablenameDeclares an input variable
outputnameExposes a value after apply
providernameConfigures a provider
modulenameCalls 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.web and aws_s3_bucket.web can 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 (or tofu fmt) before committing to keep indentation, alignment, and comment style consistent.
  • Prefer # for comments since fmt normalizes // to #.
  • Use descriptive snake_case identifiers — 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_on unnecessarily.
  • Always pin required_version in the terraform block to avoid surprises across CLI upgrades.
Last updated June 14, 2026
Was this helpful?