Terraform – HCL Syntax You Will Actually Use

July 16, 20265 min readUpdated 8/24/2026

HCL is a small language. It has no loops in the usual sense, no functions you can define, and no classes. That is deliberate — it is a configuration language, and the constraint is what keeps infrastructure readable. This lesson is the part of it you will actually use.

Everything is a block or an argument

resource "aws_ecs_cluster" "this" {
  name = "pizza-tf"
}
resource            block TYPE      — what kind of thing this is
"aws_ecs_cluster"   block LABEL     — which resource type
"this"              block LABEL     — the name YOU choose, unique per type
name = "pizza-tf"   an ARGUMENT     — a name and a value

The second label is yours and it never appears in AWS. It is the address other parts of the configuration use to refer to this resource: aws_ecs_cluster.this.id. Name it for its role, not its type — "this" is idiomatic when a module makes exactly one of something, and "public" / "private" beat "subnet1" / "subnet2" every time.

The block types you will meet are few:

terraform   settings: required versions, backend
provider    how to talk to a platform
resource    something Terraform creates and owns
data        something that already exists, which Terraform reads
variable    an input
output      a return value
locals      named intermediate values
module      a call to another directory of Terraform

Types

HCL has three primitive types and three collection types.

locals {
  # primitives
  name    = "pizza-tf"     # string
  port    = 8085           # number  — no separate int/float
  enabled = true           # bool

  # collections
  azs = ["us-west-2a", "us-west-2b"]        # list — ordered, same type

  tags = {                                   # map — string keys, same type
    Project   = "terraform-tutorial"
    ManagedBy = "terraform"
  }

  service = {                                # object — fixed keys, MIXED types
    name = "api"
    port = 8085
    tags = ["web", "public"]
  }
}

The distinction that matters: a map has arbitrary keys and one value type, an object has known keys and per-key types. They look identical in HCL — the same { } — and Terraform infers which you meant. It only becomes visible when you write a type constraint on a variable, which lesson 6 covers.

There is also null, and it is genuinely useful: setting an argument to null means "as if I had not written this line", so the provider default applies. That is how you conditionally omit an argument.

Strings and interpolation

locals {
  prefix = "pizza-tf"

  # ${...} interpolates an expression into a string
  db_name  = "${local.prefix}-mysql"
  log_group = "/ecs/${local.prefix}"

  # ⚠️ Do NOT do this. It is a string containing one interpolation and
  # nothing else, so the quotes and ${} do nothing but add noise.
  bad  = "${local.prefix}"
  good = local.prefix
}

That last one is the most common thing terraform fmt and reviewers pick up. If the whole string is one expression, write the expression.

For anything multi-line — a policy document, a startup script — use a heredoc:

locals {
  # <<-EOT (with the dash) strips the leading indentation, so the block can be
  # indented to match the code around it without the indentation ending up in
  # the value. Without the dash, every space is part of the string.
  startup = <<-EOT
    #!/bin/bash
    echo "starting ${local.prefix}"
    systemctl start docker
  EOT
}

⚠️ For JSON — IAM policies especially — do not use a heredoc. Use jsonencode():

locals {
  # A heredoc full of JSON is a string. A typo in it is discovered at APPLY
  # time, by AWS, as a parse error. jsonencode takes real HCL values, so a
  # missing brace is a syntax error in your editor and a trailing comma is
  # simply allowed.
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["secretsmanager:GetSecretValue"]
      Resource = ["arn:aws:secretsmanager:us-west-2:123456789012:secret:example"]
    }]
  })
}

The functions worth knowing

There are around 100 built-in functions and you cannot define your own. In practice a handful cover almost everything:

locals {
  base = { Project = "terraform-tutorial" }

  # merge — combine maps, later keys win. The single most-used function
  # in real configurations, because it is how you add a Name to shared tags.
  tags = merge(local.base, { Name = "pizza-tf-vpc" })

  # lookup — read a map with a fallback instead of erroring
  size = lookup(local.base, "InstanceSize", "small")

  # coalesce — first non-null, non-empty value
  region = coalesce(null, "", "us-west-2")

  # try — first expression that does not error. The polite way to handle
  # a value that may not exist yet.
  maybe = try(local.base.Missing, "default")

  # join / split
  origins = join(",", ["http://a.example", "http://b.example"])
  parts   = split(",", "a,b,c")

  # slice / length / element — list handling
  first_two = slice(["a", "b", "c", "d"], 0, 2)

  # cidrsubnet — carve a subnet out of a VPC range without doing the
  # arithmetic yourself. cidrsubnet("10.20.0.0/16", 8, 1) = "10.20.1.0/24"
  subnet = cidrsubnet("10.20.0.0/16", 8, 1)

  # file / templatefile — read a file from disk, optionally with variables
  # NOTE: read at PLAN time, from the machine running Terraform.
  # user_data = templatefile("${path.module}/init.sh.tftpl", { port = 8085 })
}

terraform console

You do not have to guess what an expression evaluates to. terraform console is a REPL over your configuration:

$ terraform console
> cidrsubnet("10.20.0.0/16", 8, 1)
"10.20.1.0/24"

> merge({ a = 1 }, { b = 2 })
{
  "a" = 1
  "b" = 2
}

> [for s in ["a", "b"] : upper(s)]
[
  "A",
  "B",
]

It loads state too, so you can inspect real attributes of things you have already built. It is the fastest way to answer "what shape is this value actually?", which is most of the confusion when an expression will not compile.

Comments

# the idiomatic one
// also works
/* block comment,
   rarely used */

Use them. Infrastructure code is read far more often than written, and almost every non-obvious line in it is non-obvious because of something AWS does — which is exactly the thing worth writing down.

terraform fmt

terraform fmt              # rewrite files in this directory
terraform fmt -recursive   # and every subdirectory
terraform fmt -check       # exit non-zero instead of rewriting — for CI

It aligns the = in consecutive arguments, fixes indentation, and removes the redundant interpolation shown earlier. Run it before every commit and put fmt -check -recursive in CI, and formatting stops being something anyone discusses in a review.

Files and layout

Terraform reads every .tf file in the directory and concatenates them. The names are pure convention, but the convention is near-universal and worth following:

main.tf        the resources
variables.tf   the inputs
outputs.tf     the outputs
versions.tf    the terraform{} block and provider requirements

Subdirectories are not read. A directory is the unit of everything in Terraform — one state file, one init, one apply. A subdirectory is a separate configuration unless you explicitly call it as a module, which is lesson 10.

Next

You can read HCL now. Lesson 4 is about the plugin that turns it into AWS API calls — how to pin it, and why the lock file belongs in git.