Learn Terraform - HCL Syntax Anatomy & Core Workflow
Episode 2 of 21

Learn Terraform - HCL Syntax Anatomy & Core Workflow

Time to write your first Terraform code: understanding the anatomy of HCL syntax from blocks, arguments, to identifiers, then practicing the four core workflow steps — init, plan, apply, and destroy.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

In episode 1, we discussed the long history of why Terraform was born: from inconsistent manual ClickOps, fragile imperative shell scripting, to declarative Infrastructure as Code — and why Terraform with its state management and dependency graph became the primary choice in the modern world. Well, in this episode 2, we'll start touching real code.

In this episode we'll discuss two foundations you'll use in every remaining episode of this series. First, the HCL syntax anatomy (HashiCorp Configuration Language) — Terraform's configuration language — starting from block structure, arguments, and identifiers. Second, the four core workflow steps of Terraform: terraform init, terraform plan, terraform apply, and terraform destroy. These two foundations are like learning "the alphabet and grammar" before writing a long essay: without mastering them, the following episodes (providers, resources, variables, state, modules) will feel like reading a book without knowing the letters.

Why is this important in the real world? Because this is the workflow that engineering teams around the world execute hundreds of times every day — including in CI/CD pipelines we'll discuss in episode 13. Understanding what happens behind each command will differentiate you from an operator who just "runs it" into an engineer who can debug and explain Terraform's behavior.

HCL Syntax Anatomy

HCL was designed with one philosophy: easy to read for humans, easy to understand for machines. Before writing configuration, let's break down the three core concepts that form every .tf file: Block, Argument, and Identifier.

Block: The Main Structure

A Block is the main unit in HCL. It starts with a keyword, followed by label pairs, then content wrapped in curly braces { }. Think of a block as a "box" that holds configuration for one specific thing.

General anatomy of a block
resource "aws_instance" "web" {
  # block content: argument
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"
}

The structure above reads as: I declare a resource of type aws_instance named web. The general form is:

General HCL block pattern
<block_type> "<label_1>" "<label_2>" {
  argument = value
}

The number of labels varies depending on the block type:

Block TypeNumber of LabelsExample
resource2 labels: type & nameresource "aws_instance" "web"
data2 labels: type & namedata "aws_ami" "ubuntu"
provider1 label: nameprovider "aws"
variable1 label: namevariable "region"
output1 label: nameoutput "public_ip"
terraform0 labelsterraform { }
module1 label: namemodule "vpc"

Argument: Setting Values

An Argument is a key-value pair inside a block. It's the "material" that configures the block. In the resource "aws_instance" "web" block, the ami and instance_type arguments tell Terraform the desired instance specs.

Block containing arguments
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"
 
  tags = {
    Name        = "web-server"
    Environment = "production"
  }
}

Notice that argument values can be simple (string, number, boolean) or complex (list, map, even nested blocks like tags). Arguments can also reference other resources — this is the basis of the automatic dependency graph discussed in episode 1.

Argument referencing another resource
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.app.id
}

The aws_subnet.app.id reference is the attribute reference syntax: aws_subnet (block type) → app (name) → id (exported attribute). As soon as Terraform sees this reference, it automatically creates a dependency: the instance waits for the subnet to be created first.

Identifier: Names That Give Meaning

An Identifier is the label you give to name a block — in the examples above, web, app, and ubuntu. Identifiers serve two roles:

  1. Unique within scope — two resources of the same type can't share the same identifier.
  2. Reference address — the identifier becomes how we call that resource from other resources (aws_instance.web.public_ip).

Tip

Choose descriptive and consistent identifiers. web, db, bastion are much better than r1, r2, r3 — because infrastructure code is read more often than written, and these identifiers will appear many times as references between resources.

Caution

A common beginner mistake: writing arguments with names that don't exist in the provider documentation. Terraform is very strict — instancetype (wrong) vs instance_type (right) will immediately trigger an Unsupported argument error. Use VS Code autocomplete and always check the official provider documentation for the list of valid arguments.

Our First Configuration

Time to practice. As a first safe and free example, we'll use the random provider (built-in by HashiCorp, no cloud account needed) and the local provider to write files on your laptop. This example lets you experience Terraform's entire workflow without any cost risk.

Create a new project folder, then fill in the following main.tf:

main.tf
terraform {
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
  }
}
 
resource "random_id" "server_id" {
  byte_length = 4
}
 
resource "local_file" "server_info" {
  filename = "server.txt"
  content  = "Server ID: ${random_id.server_id.hex}\n"
}

What's happening in this code?

  • The terraform block declares the providers needed — this will be studied in depth in episode 3.
  • The resource "random_id" "server_id" block generates a random 4-byte ID (8 hex characters).
  • The resource "local_file" "server_info" block writes a server.txt file containing that random ID. Notice the ${random_id.server_id.hex} reference — this is the interpolation that connects the two resources.

The Four Core Workflow Steps

Now comes the heart of this episode. Terraform has four main commands that form the full lifecycle of a project. Let's discuss them one by one, complete with their output.

1. terraform init

The first command that must be run in any Terraform project. Its job: download the declared providers, initialize the backend (where state is stored), and prepare modules. Without init, other commands will refuse to run with the Missing required provider message.

terraform init
terraform init
 
Initializing the backend...
 
Initializing provider plugins...
- Finding hashicorp/local versions matching "~> 2.5"...
- Finding hashicorp/random versions matching "~> 3.6"...
- Installing hashicorp/local v2.5.2...
- Installed hashicorp/local v2.5.2 (signed by HashiCorp)
- Installing hashicorp/random v3.6.3...
- Installed hashicorp/random v3.6.3 (signed by HashiCorp)
 
Terraform has been successfully initialized!
 
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

Note

terraform init is idempotent — safe to run repeatedly. It also automatically creates the hidden .terraform/ folder containing the downloaded provider cache. This folder must not be committed to Git (add it to .gitignore), because its contents can be regenerated at any time with init.

2. terraform plan

terraform plan is a preview of changes without touching the real environment. Terraform compares three things: your HCL code, the state file, and the actual condition in the cloud. The result is a list of operations that would be performed if you ran apply.

terraform plan
terraform plan
 
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
 
Terraform will perform the following actions:
 
  # local_file.server_info will be created
  + resource "local_file" "server_info" {
      + content              = "Server ID: 1a2b3c4d5e6f\n"
      + content_base64sha256 = (known after apply)
      + filename             = "server.txt"
      + id                   = (known after apply)
    }
 
  # random_id.server_id will be created
  + resource "random_id" "server_id" {
      + b64_std     = (known after apply)
      + b64_url     = (known after apply)
      + byte_length = 4
      + id          = (known after apply)
    }
 
Plan: 2 to add, 0 to change, 0 to destroy.

Notice the three symbols that appear in the plan output — these are Terraform's "universal language":

SymbolMeaningWhen It Appears
+Add (create)New resource that doesn't exist in the cloud yet
~Update in-place (change)Resource exists, but its attributes differ from the code
-/+Replace (replace)Resource exists, but its attributes can't be changed in-place
-Destroy (delete)Resource exists in state but is no longer in the code

The summary line at the very bottom — Plan: 2 to add, 0 to change, 0 to destroy — is the summary of all operations. Reading this line quickly is an important everyday skill: before doing apply, you must make sure that what will be created/changed/deleted matches expectations. Why? Because a "0 to destroy" line you expected that turns out to be "5 to destroy" could mean you're about to delete production infrastructure unintentionally.

Important

The golden rule of a Terraform engineer: always read the terraform plan output before apply. Never blind-run apply without looking at the plan — especially in production environments. This is what separates a responsible engineer from a careless one.

3. terraform apply

terraform apply executes the plan in the real world. Terraform will show the plan again, then ask for confirmation (yes) — unless you add the -auto-approve flag.

terraform apply
terraform apply
 
Terraform will perform the following actions:
  # random_id.server_id will be created
  + resource "random_id" "server_id" {
      + b64_std     = (known after apply)
      + byte_length = 4
      + id          = (known after apply)
    }
 
  # local_file.server_info will be created
  + resource "local_file" "server_info" {
      + content  = "Server ID: 1a2b3c4d5e6f\n"
      + filename = "server.txt"
      + id       = (known after apply)
    }
 
Plan: 2 to add, 0 to change, 0 to destroy.
 
Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.
 
  Enter a value: yes
 
local_file.server_info: Creating...
local_file.server_info: Creation complete after 0s [id=...]
random_id.server_id: Creating...
random_id.server_id: Creation complete after 0s [id=...]
 
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

After apply succeeds, several things happen:

  • Real resources are created in the cloud (or in this case, a server.txt file is written on your laptop).
  • Terraform creates the terraform.tfstate file — Terraform's "memory" that maps your code to real resources.
  • The Apply complete! Resources: 2 added, 0 changed, 0 destroyed. output confirms the execution result.

Try verifying the result:

Verify the apply result
cat server.txt
Server ID: 1a2b3c4d5e6f

Warning

After apply, a terraform.tfstate file appears in your project folder. This file contains code-to-resource mappings and can hold sensitive data (IPs, IDs, even secrets in certain cases). Never commit it to Git. In episode 5 we'll discuss how to store it in a secure remote backend, and in episode 15 how to secure the secrets inside it.

4. terraform destroy

terraform destroy deletes all infrastructure managed by the configuration. It's the opposite of apply: it reads the state, finds all resources, and deletes them one by one.

terraform destroy
terraform destroy
 
  # local_file.server_info will be destroyed
  - resource "local_file" "server_info" {
      - content              = "Server ID: 1a2b3c4d5e6f" -> null
      - content_base64sha256 = "..." -> null
      - filename             = "server.txt" -> null
    }
 
  # random_id.server_id will be destroyed
  - resource "random_id" "server_id" {
      - b64_std     = "..." -> null
      - byte_length = 4 -> null
      - id          = "..." -> null
    }
 
Plan: 0 to add, 0 to change, 2 to destroy.
 
Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.
 
  Enter a value: yes
 
local_file.server_info: Destroying...
local_file.server_info: Destruction complete after 0s
random_id.server_id: Destroying...
random_id.server_id: Destruction complete after 0s
 
Destroy complete! Resources: 2 destroyed.

Warning

Terraform itself states it in its prompt: "There is no undo." Running destroy in the wrong environment (for example production) without reading the plan first is one of the biggest incidents in the IaC world. Always confirm the target environment before destroying, and in production, consider prevent_destroy = true for vital resources (we discuss this in episode 7).

Summary of the Four Steps

CommandFunctionWhen to RunEffect on Cloud
terraform initDownload providers, init backend & modulesFirst time & when configuration changesNone
terraform planPreview changes (dry-run)Before every changeNone
terraform applyExecute real changesWhen code is ready & reviewedCreates/changes/deletes resources
terraform destroyDelete all managed resourcesWhen the environment is no longer neededDeletes all resources

Common Mistakes in the Early Episodes

Here are some mistakes beginners most often encounter — hopefully you can avoid them:

  1. Forgetting terraform init in a new projectMissing required provider error. Solution: run init every time there's a new provider or new project.
  2. Running apply directly without reading the plan → risk of deleting resources that shouldn't be deleted. Solution: get in the habit of reading the Plan: X to add, Y to change, Z to destroy. line.
  3. Running apply in the wrong directory → Terraform works per directory. Make sure you're in the folder containing the correct main.tf (pwd, ls *.tf).
  4. Typo in argument namesUnsupported argument error. Solution: use VS Code autocomplete and the provider documentation.
  5. Committing .terraform/ and terraform.tfstate to Git → bloats the repo and leaks data. Solution: add them to .gitignore.
  6. Deleting .terraform/ or .tfstate carelessly → Terraform loses track of resources and can't manage them anymore. Solution: don't delete, understand first (episodes 5 & 8).

Tip

The best exercise to master the workflow: repeat the initplanapply → (change configuration) → planapplydestroy cycle several times while paying attention to the changes in the Plan: X to add, Y to change, Z to destroy summary line. Also notice how the server.txt file changes. By repeating, your intuition will form on its own.

Conclusion

In this episode 2 we built the technical foundation that will be used throughout the series: understanding the HCL syntax anatomy — blocks as configuration boxes, arguments as the material filling them, and identifiers as the names connecting one resource to another — as well as mastering Terraform's four core workflow steps: init (prepare), plan (preview), apply (execute), and destroy (clean up).

Key takeaways you should bring along:

  • HCL consists of block { argument = value }, with the resource block being the one you'll use most often.
  • terraform init downloads providers; terraform plan doesn't change anything; terraform apply executes; terraform destroy deletes everything.
  • Always read the Plan: X to add, Y to change, Z to destroy line before applying.
  • The state file (terraform.tfstate) is a valuable asset — don't commit it, don't delete it.

How do you feel after running the initapplydestroy cycle for the first time? It's a nice feeling, isn't it — the ability to create and delete infrastructure with just a few commands is a superpower you've just acquired.

Now that you understand the HCL "alphabet" and the basic workflow, in the next episode 3 we'll discuss a no less exciting topic: Providers, Resources & Data Sources — understanding the bridge between Terraform and cloud APIs, the difference between managed resources vs read-only data sources, and how to leverage existing infrastructure without recreating it. Stay excited, because from here you start building real infrastructure in the cloud!