Understanding Terraform HCL Syntax: Blocks, Parameters, and Arguments
Your friendly, visual guide to reading and writing Terraform configuration files—even if you've never coded before.
📅 Published: Feb 2026
⏱️ Estimated Reading Time: 18 minutes
🏷️ Tags: Terraform, HCL, Syntax, Beginner Guide, Infrastructure as Code
📖 Introduction: What is HCL and Why Should You Care?
The Language of Terraform
HCL stands for HashiCorp Configuration Language. It's the language Terraform speaks. When you write Terraform configurations, you're writing HCL.
Think of HCL as a set of fill-in-the-blank forms. Every form has the same structure—sections to complete, fields to fill, options to choose. Once you recognize the pattern, reading and writing HCL becomes automatic.
The beautiful thing about HCL: You don't need to be a programmer to understand it. It's designed to be human-readable first, machine-executable second. Unlike general-purpose programming languages (Python, JavaScript, Go), HCL has no loops, no conditionals, no complex logic—it's declarative, not imperative.
You describe what you want. Terraform figures out how to make it happen.
The Three Core Concepts
Every single line of Terraform HCL fits into exactly one of three categories:
| Concept | Purpose | Looks like | Example |
|---|---|---|---|
| Blocks | Containers for configuration | Curly braces { } | resource "aws_instance" "web" { ... } |
| Arguments | Name-value pairs | name = value | instance_type = "t2.micro" |
| Identifiers | Names you choose | No quotes | web, my_bucket, main_vpc |
That's it. Everything else—comments, expressions, functions, meta-arguments—is just decoration around these three core concepts.
Once you understand blocks, arguments, and identifiers, you understand 80% of HCL. The remaining 20% is learning which blocks and arguments exist for each provider.
🧱 Blocks: The Building Containers
What is a Block?
A block is a container. It groups related configuration together. Blocks are always enclosed in curly braces { } and usually have a label that names them.
Visual representation:
block_type "label" "optional_second_label" {
# content goes here
# more blocks
# arguments
}Think of blocks like nesting dolls: Blocks can contain other blocks, which can contain other blocks, forming a hierarchy of configuration.
The Four Block Types You'll Use Most
Type 1: Terraform Block — Configuring Terraform Itself
terraform { required_version = ">= 1.5.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } }
What's happening:
terraformis the block typeIt has no label (some blocks don't need labels)
Inside, it contains arguments (
required_version)And nested blocks (
required_providers)
Purpose: This block configures Terraform itself—not your infrastructure. It tells Terraform which version to use, which providers to download, and where to store state.
Type 2: Provider Block — Configuring Cloud/Service Access
provider "aws" { region = "us-west-2" default_tags { tags = { Environment = "production" ManagedBy = "Terraform" } } }
What's happening:
provideris the block type"aws"is the label (identifies which provider)Inside, it contains arguments (
region)And nested blocks (
default_tags)
Purpose: This block configures how Terraform authenticates and interacts with a specific provider (AWS, Google Cloud, Azure, Kubernetes, etc.). You typically have one provider block per cloud/service you use.
Type 3: Resource Block — Creating Infrastructure
resource "aws_instance" "web_server" { ami = "ami-0c55b159cbfafe1f0" instance_type = "t2.micro" tags = { Name = "Web Server" } root_block_device { volume_size = 20 volume_type = "gp3" } }
What's happening:
resourceis the block type"aws_instance"is the first label (resource type)"web_server"is the second label (resource name—you choose this!)Inside, it contains arguments (
ami,instance_type)And nested blocks (
tags,root_block_device)
Purpose: This is the most important block. Each resource block creates one piece of infrastructure—a server, a database, a DNS record, a bucket, a Kubernetes pod.
The resource type + resource name together form a unique identifier: aws_instance.web_server
Type 4: Data Block — Fetching Existing Information
data "aws_ami" "ubuntu" { most_recent = true filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"] } owners = ["099720109477"] # Canonical's AWS account ID }
What's happening:
datais the block type"aws_ami"is the first label (data source type)"ubuntu"is the second label (data source name—you choose this!)Inside, it contains arguments (
most_recent,owners)And nested blocks (
filter)
Purpose: Data blocks don't create anything. They read existing information from your provider. This is useful when you need to reference an existing resource that Terraform didn't create.
Block Nesting: Blocks Inside Blocks
Blocks can contain other blocks. This creates a parent-child relationship:
resource "aws_security_group" "web_sg" { name = "web-server-sg" ingress { # Nested block - no label needed from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { # Another nested block from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "Web Server Security Group" } }
Notice: Some nested blocks (like ingress, egress) don't have labels. Others (like tags) use a special { ... } syntax for maps. HCL is flexible—different block types have different rules.
🏷️ Arguments: The Name-Value Pairs
What is an Argument?
An argument assigns a value to a name. It's the fundamental unit of configuration.
Syntax:
identifier = value
Always:
An identifier (letters, numbers, underscores, hyphens)
An equals sign
=A value (string, number, boolean, list, map, or another block)
Never:
No semicolons at the end
No
varorletkeywordsNo type declarations (Terraform infers types)
Argument Values: The Six Types
Type 1: Strings — Text enclosed in quotes
ami = "ami-0c55b159cbfafe1f0" name = "Web Server" description = "This is a longer string value that can span multiple lines if you want, but most people keep it on one line."
Strings with variables: Use ${ ... } interpolation
bucket = "my-app-${var.environment}-${random_string.suffix.result}"
Strings with quotes inside: Use escaping or heredoc
# Escape quotes policy = "{\"Version\":\"2012-10-17\",...}" # Heredoc (easier for multi-line) policy = <<EOF { "Version": "2012-10-17", "Statement": [...] } EOF
Type 2: Numbers — No quotes, can be integers or decimals
instance_count = 3 volume_size = 20.5 port = 8080
Terraform supports: whole numbers, decimals, and scientific notation. All numbers in Terraform are arbitrary precision.
Type 3: Booleans — true or false (no quotes!)
enabled = true enable_versioning = false publicly_accessible = true
Common mistake: Writing "true" with quotes creates a string, not a boolean. This often still works (providers coerce strings), but it's not idiomatic.
Type 4: Lists — Ordered sequences, enclosed in [ ]
availability_zones = ["us-west-2a", "us-west-2b", "us-west-2c"] security_group_ids = ["sg-12345678", "sg-87654321"] tags = ["web", "app", "frontend"]
Accessing list elements:
first_az = var.availability_zones[0] # Zero-indexed!
Type 5: Maps — Key-value collections, enclosed in { }
tags = { Name = "Web Server" Environment = "production" ManagedBy = "Terraform" } # Multi-line maps (each key-value on its own line)
Accessing map elements:
environment = var.tags["Environment"]
Maps vs. Objects: Terraform doesn't strictly distinguish—both use { } syntax. The difference is semantic: maps have arbitrary keys, objects have predefined keys.
Type 6: Null — Explicit absence of value
user_data = null # Explicitly no user data
null is rarely used directly but appears frequently in conditional expressions and module outputs. It means "no value" or "not set."
Argument Styles: When to Put Things on New Lines
Single-line style (for very simple resources):
resource "random_string" "suffix" { length = 8 }
Multi-line style (for everything else):
resource "random_string" "suffix" { length = 8 special = false upper = false }
Consistency matters. HashiCorp's official style guide recommends:
One argument per line
Align equals signs for readability (but this is optional)
Always use multi-line for resources with more than 1-2 arguments
Run
terraform fmtto automatically format your code
🏷️ Identifiers: The Names You Choose
What is an Identifier?
An identifier is a name you give to a resource, variable, output, or module. Terraform uses these names to track relationships between components.
Rules for identifiers:
Start with a letter or underscore
Followed by letters, numbers, underscores, or hyphens
Cannot contain spaces
Cannot be a reserved keyword
Valid identifiers:
web_server web-server web_server_01 _private_identifier
Invalid identifiers:
web server # No spaces allowed 2nd_server # Can't start with number web.server # No periods allowed resource # Reserved keyword
Where Identifiers Appear
1. Resource names (you choose these):
resource "aws_instance" "web_server" { # ← "web_server" is the identifier # ... }
2. Variable names (you choose these):
variable "instance_type" { # ← "instance_type" is the identifier description = "EC2 instance type" type = string default = "t2.micro" }
3. Output names (you choose these):
output "public_ip" { # ← "public_ip" is the identifier value = aws_instance.web_server.public_ip }
4. Module names (you choose these):
module "vpc" { # ← "vpc" is the identifier source = "./modules/aws-vpc" }
5. Local values (you choose these):
locals { common_tags = { # ← "common_tags" is the identifier Environment = var.environment ManagedBy = "Terraform" } }
Referencing Identifiers
Once you name something, you can reference it elsewhere:
# Define a resource resource "aws_instance" "web_server" { ami = "ami-0c55b159cbfafe1f0" instance_type = "t2.micro" } # Reference it in an output output "instance_id" { value = aws_instance.web_server.id # ← Reference using the identifier } # Reference it in another resource resource "aws_eip" "web_ip" { instance = aws_instance.web_server.id # ← Reference again }
The pattern is always: resource_type.resource_name.attribute
This creates a dependency—Terraform knows it must create the instance before the Elastic IP.
🔗 Expressions: Making Dynamic Configurations
What is an Expression?
An expression is any piece of HCL that produces a value. Literals ("hello", 42, true) are expressions. Variables (var.instance_type) are expressions. Function calls (max(5, 10)) are expressions.
Expressions turn static configurations into dynamic, reusable templates.
Variable References
The most common expression:
instance_type = var.instance_type
Reading from other resources:
vpc_id = aws_vpc.main.id
Reading from data sources:
ami_id = data.aws_ami.ubuntu.id
Reading from modules:
vpc_id = module.vpc.vpc_id
Reading from locals:
tags = local.common_tags
String Interpolation
Embed expressions inside strings using ${ ... }:
bucket = "my-app-${var.environment}-${random_string.suffix.result}"
Common uses:
Constructing unique names
Building ARNs
Creating URLs
Formatting user_data scripts
Before interpolation:
bucket = "my-app-production-x7k9m2p4"
With interpolation:
bucket = "my-app-${var.environment}-${random_string.suffix.result}"The difference between static and dynamic configuration.
Conditional Expressions
Ternary operator: condition ? true_value : false_value
instance_type = var.environment == "production" ? "t3.large" : "t3.micro"
Read as: "If environment is production, use t3.large; otherwise, use t3.micro"
More complex example:
count = var.create_bucket ? 1 : 0
This pattern appears constantly in production Terraform—conditionally creating resources based on variables.
Function Calls
Functions transform values:
# String functions lower(var.environment) # "PRODUCTION" → "production" upper(var.region) # "us-west-2" → "US-WEST-2" format("instance-%03d", count.index) # "instance-001" # Collection functions max(5, 10, 3) # 10 min(5, 10, 3) # 3 length(var.subnets) # Number of subnets # File functions user_data = file("${path.module}/user_data.sh") # JSON/YAML functions jsonencode(local.policy) # Convert map to JSON string
Terraform has over 150 built-in functions. You don't need to memorize them—just know they exist and look them up when needed.
📦 Meta-Arguments: Terraform's Special Powers
What are Meta-Arguments?
Meta-arguments are special arguments that work with almost any resource. They're not specific to AWS or Google Cloud—they're built into Terraform itself.
The Big Five meta-arguments:
| Meta-argument | Purpose | Example |
|---|---|---|
count | Create multiple instances | count = 3 |
for_each | Create instances from a map | for_each = var.subnets |
depends_on | Explicit dependencies | depends_on = [aws_instance.other] |
provider | Specify non-default provider | provider = aws.west |
lifecycle | Control resource behavior | lifecycle { create_before_destroy = true } |
count: Create Multiple Copies
resource "aws_instance" "web" { count = 3 # Create 3 EC2 instances ami = "ami-0c55b159cbfafe1f0" instance_type = "t2.micro" tags = { Name = "web-server-${count.index}" } }
Accessing count resources:
# Get the first instance's ID aws_instance.web[0].id # All instance IDs (as a list) aws_instance.web[*].id
for_each: Create from Collections
variable "subnets" { type = map(object({ cidr_block = string az = string })) default = { "subnet_a" = { cidr_block = "10.0.1.0/24" az = "us-west-2a" } "subnet_b" = { cidr_block = "10.0.2.0/24" az = "us-west-2b" } } } resource "aws_subnet" "this" { for_each = var.subnets vpc_id = aws_vpc.main.id cidr_block = each.value.cidr_block availability_zone = each.value.az tags = { Name = each.key } }
Accessing for_each resources:
# Get specific subnet by key aws_subnet.this["subnet_a"].id # All subnet IDs (as a map) aws_subnet.this[*].id # Actually returns a map, not a list!
depends_on: Explicit Dependencies
Usually Terraform automatically detects dependencies when you reference one resource in another:
# Terraform knows: security group must exist before instance resource "aws_instance" "web" { vpc_security_group_ids = [aws_security_group.web.id] }
Sometimes dependencies are hidden (e.g., an S3 bucket referenced in an IAM policy, but not directly in the resource):
resource "aws_s3_bucket" "data" { bucket = "my-app-data" } resource "aws_iam_role_policy" "bucket_access" { # No direct reference to aws_s3_bucket.data! policy = jsonencode({ Statement = [{ Effect = "Allow" Action = "s3:*" Resource = "arn:aws:s3:::my-app-data/*" }] }) # Explicit dependency: depends_on = [aws_s3_bucket.data] }
Use depends_on sparingly. Most of the time, Terraform's automatic dependency detection works perfectly.
lifecycle: Control Resource Behavior
resource "aws_instance" "web" { ami = "ami-0c55b159cbfafe1f0" instance_type = "t2.micro" lifecycle { create_before_destroy = true # Create new before destroying old prevent_destroy = true # Never delete this resource ignore_changes = [ami, user_data] # Ignore specific attribute changes } }
create_before_destroy is essential for zero-downtime deployments.
prevent_destroy is a safety lock for critical resources (databases, production VPCs).
ignore_changes prevents Terraform from reverting manual modifications.
📝 Comments: Documenting Your Intent
The Three Comment Styles
Style 1: Single-line comments — Start with #
# This bucket stores application logs resource "aws_s3_bucket" "logs" { bucket = "app-logs-${var.environment}" }
Style 2: Single-line comments — Start with // (alternative)
// This bucket stores application logs resource "aws_s3_bucket" "logs" { bucket = "app-logs-${var.environment}" }
Style 3: Multi-line comments — Enclosed in /* */
/* * This module creates a complete VPC infrastructure * including public and private subnets, NAT gateways, * and appropriate route tables. */ module "vpc" { source = "./modules/aws-vpc" }
What to Comment
DO comment:
Why you made a non-obvious decision
Which team owns this resource
Links to documentation or tickets
Workarounds for provider bugs
DON'T comment:
Obvious syntax
What a standard resource does
"This creates an EC2 instance" (the code already says that)
Good comment:
# Using t3.large instead of t3.medium due to memory constraints # See ticket: https://jira.company.com/browse/INFRA-1234 instance_type = "t3.large"
Bad comment:
# This is an EC2 instance resource "aws_instance" "web" { # I already know it's an EC2 instance!
🎯 Putting It All Together: A Complete Example
Let's examine a real-world configuration and identify every element we've learned:
# ------------------------------------------------------------ # Terraform Block - Configures Terraform itself # ------------------------------------------------------------ terraform { required_version = ">= 1.5.0" # Argument: string required_providers { # Nested block aws = { # Nested block with label source = "hashicorp/aws" # Argument: string version = "~> 5.0" # Argument: string } } } # ------------------------------------------------------------ # Provider Block - Configures AWS # ------------------------------------------------------------ provider "aws" { # Block type: provider, Label: "aws" region = var.aws_region # Argument: string with variable reference default_tags { # Nested block (no label) tags = local.common_tags # Argument: map reference } } # ------------------------------------------------------------ # Local Values - Reusable expressions # ------------------------------------------------------------ locals { # Block type: locals (no label) environment = terraform.workspace # Argument: expression common_tags = { # Argument: map Environment = local.environment ManagedBy = "Terraform" Project = "E-commerce Platform" CostCenter = "Platform-Engineering" } } # ------------------------------------------------------------ # Data Source - Fetch existing information # ------------------------------------------------------------ data "aws_ami" "ubuntu" { # Block type: data, Labels: "aws_ami", "ubuntu" most_recent = true # Argument: boolean owners = ["099720109477"] # Argument: list filter { # Nested block name = "name" # Argument: string values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"] # Argument: list } } # ------------------------------------------------------------ # Resource - Actual infrastructure # ------------------------------------------------------------ resource "aws_instance" "web_server" { # Block type: resource, Labels: "aws_instance", "web_server" count = var.instance_count # Meta-argument ami = data.aws_ami.ubuntu.id # Argument: data source reference instance_type = var.instance_type # Argument: variable reference subnet_id = element( # Argument: function call aws_subnet.public[*].id, count.index ) vpc_security_group_ids = [aws_security_group.web.id] # Argument: list with resource reference user_data = file("${path.module}/user_data.sh") # Argument: file function tags = merge( # Argument: function call local.common_tags, { Name = "web-server-${count.index + 1}" Role = "web" } ) lifecycle { # Meta-argument block create_before_destroy = true # Argument: boolean ignore_changes = [ami, user_data] # Argument: list } } # ------------------------------------------------------------ # Output - Expose information to callers # ------------------------------------------------------------ output "web_server_public_ips" { # Block type: output, Label: "web_server_public_ips" description = "Public IP addresses of web servers" value = aws_instance.web_server[*].public_ip } output "web_server_ids" { description = "Instance IDs of web servers" value = { for idx, instance in aws_instance.web_server : "server-${idx + 1}" => instance.id } }
Every single line in this file is either:
A block (with optional labels)
An argument (identifier = value)
A comment (documentation)
That's the entire language. Everything else—functions, expressions, meta-arguments—is built on these three foundations.
📋 HCL Syntax Quick Reference Card
Block Structure
TYPE "LABEL" "SECOND_LABEL" {
ARGUMENT = VALUE
NESTED_BLOCK {
ARGUMENT = VALUE
}
}Value Types
| Type | Example | Notes |
|---|---|---|
| String | "hello" | Double quotes |
| Number | 42, 3.14 | No quotes |
| Boolean | true, false | No quotes |
| List | ["a", "b", "c"] | Square brackets |
| Map | {key = "value"} | Curly braces |
| Null | null | Explicit absence |
Common Expressions
| Expression | Example | Purpose |
|---|---|---|
| Variable | var.instance_type | Reference input variable |
| Local | local.common_tags | Reference local value |
| Resource | aws_instance.web.id | Reference resource attribute |
| Data | data.aws_ami.ubuntu.id | Reference data source |
| Module | module.vpc.vpc_id | Reference module output |
| Index | list[0] | Access list element |
| Map key | map["key"] | Access map value |
| Interpolation | "${var.name}-suffix" | Embed in string |
| Conditional | cond ? val1 : val2 | Ternary operator |
| Function | max(5, 10) | Transform value |
Meta-Arguments (Work on Most Resources)
| Meta-argument | Description |
|---|---|
count = NUMBER | Create multiple instances |
for_each = MAP | Create instances from map |
depends_on = [RESOURCE] | Explicit dependency |
provider = PROVIDER.ALIAS | Non-default provider |
lifecycle { ... } | Resource lifecycle rules |
Formatting (Always Run terraform fmt)
# Bad
resource "aws_instance" "web" {
ami="ami-123"
instance_type="t2.micro"
}
# Good
resource "aws_instance" "web" {
ami = "ami-123"
instance_type = "t2.micro"
}🎓 Practice Exercises
Exercise 1: Identify the Parts
Look at this configuration and identify:
All blocks and their types
All arguments
All identifiers (names you choose)
All expressions
variable "environment" { description = "Deployment environment" type = string default = "dev" } resource "aws_s3_bucket" "data" { bucket = "my-company-data-${var.environment}" tags = { Name = "Data Bucket" Environment = var.environment } } output "bucket_name" { value = aws_s3_bucket.data.bucket }
Answer:
Blocks:
variable(1),resource(1),output(1)Arguments:
description,type,default,bucket,tags,valueIdentifiers:
"environment","data","bucket_name"Expressions:
"my-company-data-${var.environment}",var.environment(twice),aws_s3_bucket.data.bucket
Exercise 2: Fix the Syntax Errors
# This configuration has 5 syntax errors. Can you find them? resource "aws_instance" web_server { ami = ami-0c55b159cbfafe1f0 instance_type = t2.micro tags = { Name = "Web Server" Environment = "production" } root_block_device { volume_size: 20 volume_type = gp3 } }
Errors:
Resource label
web_servermissing quotes →"web_server"amivalue missing quotes →"ami-0c55b159cbfafe1f0"instance_typevalue missing quotes →"t2.micro"root_block_deviceuses:instead of=→volume_size = 20volume_typevalue missing quotes →"gp3"
Exercise 3: Write from Scratch
Write a Terraform configuration that:
Creates an S3 bucket
The bucket name includes the environment variable
The bucket has versioning enabled
Tags include "Owner" and "Purpose"
Outputs the bucket ARN
Solution:
variable "environment" { description = "Deployment environment" type = string default = "dev" } resource "random_string" "suffix" { length = 6 special = false upper = false } resource "aws_s3_bucket" "app_bucket" { bucket = "app-data-${var.environment}-${random_string.suffix.result}" tags = { Owner = "Platform Team" Purpose = "Application Data" } } resource "aws_s3_bucket_versioning" "app_bucket_versioning" { bucket = aws_s3_bucket.app_bucket.id versioning_configuration { status = "Enabled" } } output "bucket_arn" { description = "ARN of the created bucket" value = aws_s3_bucket.app_bucket.arn }
🔗 Master HCL Syntax with Hands-on Labs
You now understand the grammar of Terraform. Like learning a spoken language, you don't need to memorize every word—you need to understand the sentence structure. The vocabulary (resource types, argument names) comes with practice.
👉 Practice HCL syntax with interactive exercises and real-time validation at:
https://devops.trainwithsky.com/
Our platform provides:
Live HCL parsing and validation
Syntax error detection challenges
Block structure visualization
Real-time feedback on your configurations
Progressive exercises from simple to complex
Frequently Asked Questions
Q: Is HCL the same as JSON? Can I use JSON instead?
A: HCL is a superset of JSON—every valid JSON file is valid HCL. You can write Terraform configurations in JSON (files ending in .tf.json), but it's much more verbose and harder for humans to read. Stick with .tf files.
Q: Why does HCL use = for assignment but no semicolons?
A: HCL is designed for humans first, machines second. The = sign makes it clear you're assigning a value. Semicolons are visual noise that don't help readability. This is consistent with other modern configuration languages (YAML, TOML).
Q: When do I use ${ ... } and when do I just write the variable name?
A: Use ${ ... } only inside quotes:
# Inside quotes - need interpolation bucket = "my-bucket-${var.environment}" # Outside quotes - no interpolation needed instance_type = var.instance_type
Q: Can I have multiple blocks of the same type with the same label?
A: No. Resource names must be unique within a module. You cannot have two resource "aws_instance" "web" blocks. Use count or for_each for multiple instances.
Q: What's the difference between a block and a map?
A: Blocks have curly braces { } and can contain both arguments and nested blocks. Maps also have curly braces but can only contain key-value pairs. Some contexts (like tags) expect maps; others (like ingress) expect blocks.
Q: How do I know which arguments and blocks a resource supports?
A: Always check the documentation. Each provider's documentation lists every resource, every argument, every nested block, and every attribute. Terraform has hundreds of resources; nobody memorizes them all.
Still confused about HCL syntax? That's completely normal—everyone finds some aspects confusing at first. Post your specific question in the comments below, and our community will help clarify! 💬
Comments
Post a Comment