Skip to main content
RunBook Academy

TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform

Writing Readable HCL

Intermediate⏱ ~10 minbash

What you'll learn

  • Apply consistent indentation, alignment, and ordering so code review takes minutes instead of hours
  • Run `terraform fmt` and `terraform fmt -check` as part of local and CI workflows
  • Distinguish `terraform fmt` (whitespace and ordering) from `terraform validate` (semantic)
  • Reach for the right level of abstraction: a one-liner vs a `locals` block vs a small helper module
  • Avoid clever one-liners that nobody else can read

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.

Readable HCL is not a stylistic preference. It is an operational tool. The same change can take five minutes to review in a well-formatted file and thirty minutes in an inconsistent one. Production codebases accumulate dozens of reviews per week; small clarity wins compound. The lesson is two parts: machine-enforced formatting, and human judgment about abstraction.

What “readable” actually means

Readability is the property that lets a second engineer answer four questions quickly:

  1. What block is this? (resource "..." "..." etc.)
  2. What does it depend on? (the references in its expressions)
  3. What will change if I touch this attribute? (the diff at the end of terraform plan)
  4. What does this configuration not do? (what is absent by design)

Formatting helps with #1. Naming helps with #2. Simulation helps with #3. Comments help with #4. None of them is enough on its own.

terraform fmt — the autofixer

The CLI ships a formatter that handles whitespace, indentation, and a small amount of block ordering:

terraform fmt                   # rewrite files in place
terraform fmt -check            # exit 1 if any file is not canonical, no rewrite
terraform fmt -check -diff       # show the diff that -check would reject
terraform fmt -recursive         # descend into subdirectories

terraform fmt enforces:

  • Two-space indentation. Always. There is no knob.
  • Argument alignment. Aligned = signs across consecutive arguments in the same block.
  • Block ordering. terraform {} first, then provider, then locals, then everything else; required_providers sits inside terraform {}.
  • Comment position. Comments are aligned to the column where they were originally written; the formatter does not reflow them.

The exit code is meaningful:

$ terraform fmt -check -recursive
main.tf
variables.tf

terraform fmt returns 3 when files need rewriting. CI gates on 0. The 3 is also the signal an editor LSP can use to highlight the offending files.

terraform validate — the semantic check

terraform validate checks grammar and types after a successful terraform init. It does not contact the cloud.

terraform init -backend=false      # avoid initialising the backend in CI
terraform validate                 # local-filesystem checks only

What validate checks:

  • Block structure is correct.
  • Reference paths resolve (var.region, aws_vpc.main.id, etc.).
  • Type expressions match (list(string) does not silently become any).
  • validation blocks in variables run.

What validate does not check:

  • Whether a resource’s arguments meet the provider’s schema (provider plugins do that).
  • Whether a data source’s actual returned value matches what the configuration assumes.
  • Whether an attribute combination is legal in the cloud API.
$ terraform validate
Success! The configuration is valid.

$ terraform validate
Error: Reference to undeclared input variable

  on main.tf line 12, in resource "aws_instance" "web":
  12:   region = var.regoin

The exit code is 0 for “valid” and 1 for “invalid”. Wire the exit code into CI; do not parse the message text.

The alignment convention

Within a block, consecutive arguments align their = signs:

resource "aws_instance" "web" {
  count             = var.replicas
  ami               = "ami-0c1b8b2a3f4e5d6c7"
  instance_type     = "t3.small"
  availability_zone = element(var.azs, count.index)

  tags = {
    Name = "web-${count.index}"
  }

  root_block_device {
    volume_size = 20
    encrypted   = true
  }
}

The longest argument name sets the column. Inside root_block_device, alignment restarts (the inner block’s longest argument name sets a new column).

The convention is two spaces between the longest name and the =. Terraform’s formatter enforces alignment when the args are consecutive without a blank line; place a blank line between groups to opt out of alignment for the next group.

Naming conventions that save review time

Production codebases settle on a small set of naming rules. The exact rules are less important than consistency.

TargetConventionExample
Resource nameslower_snake_case, short noun-likeweb, db_primary, vpc_main
Variable nameslower_snake_case, descriptiveregion, replicas, instance_type
Local nameslower_snake_casecommon_tags, primary_az, subnet_cidrs
Output nameslower_snake_case, the consumer-friendly namevpc_id, web_instance_dns
Module nameslower_snake_case, the rolenetwork, compute, database

Three rules that hold across most production repos:

  1. No abbreviations in module interfaces. var.cfg is unreadable outside the file. var.configuration is. The internal cost is small; the review cost is large.
  2. Plural for collections, singular for single items. subnets is a list. subnet is one. A count = var.subnets written against a singular name silently does nothing.
  3. Suffix id, arn, dns, cidr for output values. output "vpc_id" rather than output "vpc". The third party gets the type for free.

Comments: when to write them, when not to

The same comment can help or hurt. Two rules that hold:

  1. Comment the why, not the what. The name of the resource and the arguments tell the reviewer what. The comments should explain choices that are not obvious: a regulatory requirement, a work-around for a provider bug, a constraint from the upstream team.
  2. Keep comments current. A comment that disagrees with the code is a future trap. Delete or rewrite stale comments during code review.

A useful pattern for modules:

###############################################################################
# Inputs
###############################################################################

variable "region" {
  type        = string
  description = "AWS region. Pinned to eu-west-1 by platform/cost/SLA-001."
  default     = "eu-west-1"
}

The description is part of the module’s contract. The banner comment is a visual landmark. Neither is a substitute for code that reads clearly on its own.

Choosing the right abstraction

Three levels:

Level 1: a literal

instance_type = "t3.small"

Use when the value is genuinely constant and the abstraction overhead would obscure that.

Level 2: a var or a local

instance_type = var.instance_type

instance_type = local.primary_instance_type

var is a module input — the caller controls it. local is internal — the module computes it. The rule: if the caller might want to change it, expose it as var. If only the module’s internals care, keep it as local.

Level 3: a separate module

module "compute" {
  source = "../compute"

  region        = var.region
  instance_type = local.primary_instance_type
  replicas      = var.replicas
}

A separate module is an interface boundary. It pays off when the same chunk of configuration is used in more than one place, or when it is large enough (more than ~150 lines) that keeping it inline makes the file hard to navigate. The cost is that you also commit to the module’s interface for as long as you maintain the module.

The cost of clever one-liners

A clever one-liner is an expression that does in one line what would take three lines if written plainly. Two flavours:

  • Type-pun one-liners. flatten([for ... in list_of_lists : ...]) chained with a merge and a tolist. Compactly expresses five logical steps.
  • Substitution tricks. format("%s-%s", lookup(...), lookup(...)) against a per-environment map.

Clever one-liners are cheap to write and expensive to read. The break-even point is the second engineer who needs to understand the expression in a hurry during an incident. Production rule: write the expression the long way when the long way is one extra line.

# clever
allowed_cidrs = join(",", [for c in var.cidrs : c if can(cidrnetmask(c))])

# readable
locals {
  allowed_cidrs = [for c in var.cidrs : c if can(cidrnetmask(c))]
  allowed_csv   = join(",", local.allowed_cidrs)
}

The locals form names the intermediate value. The next reader sees local.allowed_csv and knows what the line produces.

Production failure modes

  1. CI does not run terraform fmt -check. Misaligned or non-canonical files land in main. Symptom: noisy diffs. Recovery: add the check, run terraform fmt -recursive, commit the result.
  2. CI runs terraform fmt and checks for exit 0 but ignores terraform validate. Files are well-formed but semantically broken. Symptom: apply-time errors. Recovery: add terraform init -backend=false && terraform validate.
  3. Megafiles. A single main.tf of 800 lines. Symptom: slow reviews, two engineers editing the same block. Recovery: split by domain (network.tf, compute.tf, iam.tf, locals.tf, outputs.tf, variables.tf).
  4. One-file modules. A module wrapped around three resources. Symptom: indirection with no benefit. Recovery: inline the module, expose the resources directly.
  5. Comments that lie. A # TODO from a previous sprint. Symptom: future time spent hunting an explanation that does not exist. Recovery: delete or move to an issue tracker.
  6. Mixed naming conventions. Variable cdn_enabled in one file and cdnEnable (camelCase) in another. Symptom: cosmetic drift, eventually real bugs. Recovery: enforce one convention in a linter.

References

What comes next

The next lesson is HCL Style and Patterns: where to put tags, where to put lifecycle, where to put depends_on, and how to wire tflint into CI so the discipline is enforced instead of debated.

Verification

terraform fmt -check -recursive -diff
terraform init -backend=false
terraform validate

Expected:

$ terraform fmt -check -recursive
$ echo $?
0

$ terraform validate
Success! The configuration is valid.

If terraform fmt -check is non-zero, run terraform fmt -recursive and commit the diff. If terraform validate produces diagnostics, treat each as a fix-the-source task; do not “fix” by adding lifecycle { ignore_changes } or by mutating the configuration to work around the message.

Knowledge check · 7 questions

  1. Q1. What does `terraform fmt` enforce?

  2. Q2. Which command exits non-zero on a misformatted file without changing anything?

  3. Q3. `terraform validate` contacts the cloud provider to verify resource arguments.

  4. Q4. Which level of abstraction is right for a chunk of configuration that is used in three other modules and exceeds 200 lines?

  5. Q5. Which of these are properties of a readable Terraform configuration? (Select all that apply.)

  6. Q6. CI is enforcing `terraform fmt -check -recursive && terraform validate`. What is missing for a robust sanity pipeline?

  7. Q7. A reviewer spots a `cidr_block = lookup(...)` expression with three nested ternary operators on the right. What is the right reviewer feedback?

Passing score: 75%. Answers are checked in this browser.