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 valueThe 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 TerraformTypes
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 CIIt 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 requirementsSubdirectories 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.