Terraform Basics #2 HCL Core Syntax: Blocks, Arguments, Resource Addresses, and the Three Shapes of Change

5 min read

In the previous post we created and destroyed an S3 bucket with a single main.tf. Back then I promised “syntax in the next part” — time to keep that promise. Terraform code is written in a dedicated language called HCL (HashiCorp Configuration Language). There is far less to learn than in a general-purpose language, and once you grasp the structure, you can pick up a resource you have never seen before and use it straight from the docs. In this post we get the skeleton — blocks and arguments — plus resource addresses, references, and reading plan output into muscle memory.

The skeleton of HCL: blocks and arguments #

An HCL file consists entirely of two things: blocks and arguments. Look at the code from the previous post again.

main.tf
resource "aws_s3_bucket" "hello" {
  bucket = "my-terraform-hello-20260822"

  tags = {
    ManagedBy = "terraform"
  }
}
  • A block has the form block_type "label" { ... }. A resource block takes two labels (resource type and name), a provider block takes one, and a terraform block takes none. How many labels a block takes is determined by its type.
  • An argument is one name = value line inside a block body. bucket = "..." is an argument.
  • Blocks can nest inside blocks. tags here is an argument whose value happens to be a map, but there are also nested blocks like lifecycle { ... } (which we will meet later) where the braces follow directly with no =.
  • Comments start with #.

That is essentially the entire grammar. What differs from resource to resource is “which arguments it accepts,” and that is documented per resource in the provider docs. Knowing HCL is less about memorizing syntax and more about looking up arguments in the docs and filling them in.

Resource addresses: type and name #

In resource "aws_s3_bucket" "hello", the two labels joined together — aws_s3_bucket.hello — form this resource’s address. The second label is where beginners get confused most often. hello is not a value sent to AWS; it is a name used only inside your Terraform code. The actual bucket name is the value of the bucket argument, and the word hello never appears anywhere in the console. That is why, when you declare multiple resources of the same type, you give each one a different name to tell them apart.

The address is used in three places: when plan output points at a resource, when another resource references it, and when the state commands we cover in #5 need a target. Pick names that tell a reader what the resource is for (logs, static_assets) — names like bucket1 will torment your future self a month from now.

Referencing one resource from another #

Infrastructure rarely ends at a single resource. Let’s attach versioning to the bucket. In the AWS provider, versioning is a separate resource.

main.tf
resource "aws_s3_bucket" "hello" {
  bucket = "my-terraform-hello-20260822"
}

resource "aws_s3_bucket_versioning" "hello" {
  bucket = aws_s3_bucket.hello.id

  versioning_configuration {
    status = "Enabled"
  }
}

The key line is bucket = aws_s3_bucket.hello.id. Write the address, add a dot, add an attribute name, and you get that resource’s value. Because we connected the two with a reference instead of typing the bucket name as a string again, this line never needs to change even if the bucket name does. Which attributes you can reference is also documented, at the bottom of each resource page in the provider docs (Attribute Reference). References carry one more meaning beyond syntax: Terraform looks at them to decide creation order. That story comes in #6.

The three shapes of change: create, update, replace #

Run terraform apply with this code, then change one tag and run plan again.

Plan output
  # aws_s3_bucket.hello will be updated in-place
  ~ resource "aws_s3_bucket" "hello" {
      ~ tags = { ... }
    }

The symbol is ~, not +. This is an update in-place: keep the existing bucket and fix only the tag. Now try changing the value of the bucket argument itself.

Plan output
  # aws_s3_bucket.hello must be replaced
-/+ resource "aws_s3_bucket" "hello" {
      ~ bucket = "my-terraform-hello-20260822" -> "new-name" # forces replacement
    }

-/+ means destroy and recreate. An S3 bucket’s name cannot be changed after creation, so renaming means replacement — there is no other way. If there was data inside, it goes down with the bucket, so whenever you see this symbol in a plan, stop and check. In summary, plan has four symbols.

SymbolMeaning
+Create
~Update in place
-/+Destroy and recreate (replace)
-Destroy

Which attributes force replacement is decided by the provider, and the # forces replacement comment in plan output tells you. How to control replacement is covered in detail in #7.

fmt and validate: the pre-commit habit #

HCL comes with an official formatter built in.

Run
terraform fmt        # normalize alignment and indentation of .tf files in the current directory
terraform validate   # check syntax and reference errors (no AWS calls)

fmt normalizes indentation and = alignment to the standard style, and validate catches errors in the code itself (misspelled arguments, references to nothing) without connecting to AWS. Both finish in seconds, so make running them before every commit a habit and style nitpicks disappear from your reviews. Enforcing this in CI as a team is a topic for the operations series.

Recap #

What we covered in this post:

  • HCL consists of two things, blocks and arguments. Per-resource arguments are not something to memorize but something to look up in the provider docs
  • The hello in aws_s3_bucket.hello is a name used only inside Terraform and is never sent to AWS. Pick names that reveal the resource’s role
  • Reference another resource’s value with address.attribute. Connecting by reference instead of copying strings makes the code resilient to change
  • Among the plan symbols +, ~, -/+, -, the -/+ (replace) means the resource gets destroyed and recreated — always double-check it
  • terraform fmt and validate are pre-commit habits

In the next post (#3 Variables, Outputs, and Locals) we pull the values we have been hardcoding out into variables — the first step toward building dev and prod from the same code.

X