Terraform · Part 4

Terraform — Part 4 — HCL Basics: Providers, Resources, and Blocks

Every .tf file so far has been used without explaining its grammar. This part slows down on HCL itself: what a block actually is, the shapes the provider and resource blocks take, and how resources reference each other.

Quick idea: almost everything in a Terraform file is a block — a type keyword, one or more labels, and a body of arguments in braces. Learn that one shape and most HCL reads itself.
Block

A type keyword, labels, and a body — the one structural shape almost everything in HCL is built from.

Argument

A name = value pair inside a block’s body.

Reference

Pointing at another resource’s attribute with type.name.attribute, which also tells Terraform about a dependency.

Introduction

Part 2 and Part 3 both used .tf files without explaining their grammar. That configuration read reasonably even without a formal syntax lesson, because HCL was deliberately designed to. This part makes that reading skill deliberate instead of accidental.

The Anatomy of a Block

Nearly every construct in HCL — resource, provider, variable, output, module, terraform itself — is written as a block: a type keyword, followed by zero or more quoted labels, followed by a body in curly braces containing arguments and sometimes nested blocks.

<BLOCK TYPE> "<LABEL 1>" "<LABEL 2>" {
  <ARGUMENT NAME> = <VALUE>
}

How many labels a block takes depends on its type. A resource block takes two labels — the resource type and a local name. A provider block takes one — the provider name. A bare terraform block takes none at all. Once you recognise this one shape, reading an unfamiliar block type is mostly a matter of counting labels.

Comments

HCL supports three comment styles: # for a single line (the idiomatic style used throughout this series and most published configurations), // as an alternative single-line form, and /* */ for a block spanning multiple lines.

# This is the idiomatic single-line comment style
// This also works, but is seen less often in practice
/* This form can
   span multiple lines */

The terraform Block

Part 2’s required_providers block actually lives inside a bare terraform block, which takes no labels at all:

terraform {
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
  required_version = ">= 1.2"
}

required_providers declares which providers the configuration depends on and where to fetch them from — this is what terraform init reads. required_version is a separate, optional argument that pins the minimum Terraform CLI version the configuration expects.

The provider Block

A provider block configures a provider’s runtime behaviour — credentials, region, endpoint — separately from the version constraint declared in required_providers:

provider "aws" {
  region = "us-east-1"
}

The single label is the provider’s short name. Everything inside the body is defined by that specific provider, so an AWS provider block and an Azure provider block will take entirely different arguments — region means nothing to a provider that has no concept of AWS regions.

Key difference: required_providers says which provider and which version. provider says how to actually talk to it. They serve different jobs and both are usually needed.

The resource Block

A resource block is where actual infrastructure gets declared. It takes two labels — the resource type, defined by the provider, and a local name you choose, used only to refer to this resource elsewhere in the configuration:

resource "random_pet" "server" {
  length = 2
}

Here, random_pet is the type — a resource this particular provider (hashicorp/random) knows how to create — and server is the local name. The local name never appears in the real infrastructure; it exists purely so the configuration can refer back to this specific resource.

Meta-Arguments: A First Look

A handful of special arguments work inside almost any resource block regardless of its type, because Terraform itself interprets them rather than the provider. These are called meta-arguments, and Part 9 covers all four in depth:

count

Creates multiple near-identical instances of a resource from one block.

for_each

Creates one instance per item in a map or set, each independently addressable.

depends_on

Forces an explicit ordering dependency Terraform couldn’t infer on its own.

A fifth, lifecycle, is a nested block rather than a simple argument, and also gets its full treatment in Part 9.

Referencing Resources

To use one resource’s result inside another, reference it with type.name.attribute. This is where the two labels from a resource block earn their keep:

resource "random_pet" "server" {
  length = 2
}

resource "local_file" "hello" {
  filename = "${path.module}/hello.txt"
  content  = "Server name: ${random_pet.server.id}"
}

random_pet.server.id reads as: the resource of type random_pet, local name server, attribute id — the generated pet name itself. Wrapping a reference in ${ } inside a string is string interpolation, HCL’s way of embedding an expression’s value into text.

Important: writing this reference does more than pull in a value. It also tells Terraform that local_file.hello depends on random_pet.server, so Terraform creates the pet name first and the file second, automatically, without you writing depends_on anywhere.

This automatic dependency detection from references is why depends_on is the exception rather than the rule — most of the time, simply referencing a value is enough for Terraform to work out the correct order on its own.

Naming Rules

Local names, argument names, and block labels follow the same identifier rules: letters, digits, underscores, and hyphens are allowed, but an identifier cannot start with a digit. Most published configurations stick to lowercase letters, digits, and underscores by convention, reserving hyphens for places like file paths and tags.

Putting It Together

The full example above already demonstrates most of what this part covered: a terraform block declaring providers, resource blocks with type and local-name labels, an implicit dependency created purely by referencing another resource’s attribute, and string interpolation to embed that value into a piece of text.

terraform {
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
  }
}

resource "random_pet" "server" {
  length = 2
}

resource "local_file" "hello" {
  filename = "${path.module}/hello.txt"
  content  = "Server name: ${random_pet.server.id}"
}

Running terraform init then terraform apply against this configuration generates a random two-word name, then writes it into hello.txt — with Terraform working out on its own that the pet name has to exist before the file can be written.

Quick Reference Summary

Term Meaning
Block Type keyword, labels, and a body in braces — the core structural shape of HCL.
Argument A name = value pair inside a block’s body.
Label A quoted string following a block’s type keyword, e.g. the type and name on a resource block.
Meta-argument An argument Terraform itself interprets on any resource, regardless of provider (count, for_each, depends_on, provider).
Reference type.name.attribute — reads another resource’s value and creates an implicit dependency.
Interpolation ${ } — embeds an expression’s value inside a string.

Final Thoughts

HCL has very little hidden syntax once the block shape clicks: a type, some labels, a body of arguments. Everything more advanced later in this series — variables, modules, data sources — reuses this exact same shape.

The other habit worth keeping from this part is trusting the implicit dependency graph. Reaching for depends_on before checking whether a plain reference already does the job is one of the more common ways newcomers write more configuration than they need to.

Key takeaway: if you can read type "label" "label2" { argument = value }, you can read almost any block in HCL — the rest is just which type, which labels, and which arguments a given block accepts.
Next in this series

Terraform — Part 5 — Variables, Outputs, and Locals. We cover how to parameterise configuration instead of hard-coding values, and how to surface results back out of it.