HCL Syntax & Resources

Understand Terraform's configuration language and resource blocks

8 min read

HCL Syntax & Resources

In the previous tutorial, we created our first S3 bucket with Terraform. Now let's understand the language we were writing in.

Terraform uses HCL (HashiCorp Configuration Language). It's not a programming language — it's a configuration language. Simple, readable, declarative. Think of it as a recipe card: you list the ingredients (resources) and the instructions (arguments), and Terraform does the cooking.

Basic Syntax

Blocks

Everything in Terraform is a block. Blocks are like containers that hold your configuration:

block_type "label1" "label2" {
  argument = "value"
  
  nested_block {
    another_argument = "another value"
  }
}

Blocks have:

  • Type — what kind of block (resource, variable, provider, etc.)
  • Labels — identifiers (number depends on block type)
  • Body — arguments and nested blocks inside {}

That's the entire structure. Once you see it, you can't unsee it — everything follows this pattern.

Arguments

Arguments are key-value pairs — you're just setting properties:

name     = "my-server"
count    = 3
enabled  = true
tags     = { env = "prod" }

Comments

# Single line comment

// Also a single line comment (less common)

/*
Multi-line
comment
*/

Resources

Resources are THE main building blocks. They represent actual infrastructure objects — servers, databases, buckets, you name it.

Resource Syntax

"How do I tell Terraform to create something?"

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
}

Breaking it down:

  • resource — block type ("hey Terraform, I want a thing")
  • "aws_instance" — resource type (provider_resourcetype)
  • "web" — local name (your nickname for this resource)
  • ami, instance_type — arguments specific to this resource type

Resource Types

Resource types follow the pattern provider_resource. It's like a naming convention — once you know the provider, you can guess most resource names:

Resource TypeWhat it creates
aws_instanceEC2 instance
aws_s3_bucketS3 bucket
aws_vpcVPC
aws_iam_roleIAM role
aws_lambda_functionLambda function

Find all AWS resources: registry.terraform.io/providers/hashicorp/aws/latest/docs

Local Name

"What's that second label for?"

The local name ("web" in the example) is how you reference this resource elsewhere in your config. It's like giving your resource a nickname:

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
}

resource "aws_eip" "web_ip" {
  instance = aws_instance.web.id  # Reference the instance
}

Pattern: resource_type.local_name.attribute

See how aws_eip.web_ip references aws_instance.web.id? Terraform sees that connection and automatically knows to create the instance first. Smart, right?

Providers

Providers are plugins that talk to APIs. They're the translators between your HCL and the actual cloud service:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

Multiple Providers

"What if I need resources in different regions?"

Use the same provider with an alias:

provider "aws" {
  region = "us-east-1"
}

provider "aws" {
  alias  = "west"
  region = "us-west-2"
}

# Use default (us-east-1)
resource "aws_s3_bucket" "east_bucket" {
  bucket = "my-east-bucket"
}

# Use west provider
resource "aws_s3_bucket" "west_bucket" {
  provider = aws.west
  bucket   = "my-west-bucket"
}

Data Types

HCL supports all the types you'd expect. Nothing crazy here.

Strings

name = "my-server"

# Multi-line (heredoc)
policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": []
}
EOF

# Template string
greeting = "Hello, ${var.name}!"

Numbers

count    = 3
cpu      = 2.5

Booleans

enabled = true
public  = false

Lists (Arrays)

availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]

# Access by index
first_az = availability_zones[0]  # "us-east-1a"

Maps (Objects)

tags = {
  Name        = "web-server"
  Environment = "production"
  Team        = "platform"
}

# Access by key
env = tags["Environment"]  # "production"
# Or
env = tags.Environment     # "production"

Nested Structures

settings = {
  database = {
    host = "localhost"
    port = 5432
  }
  cache = {
    enabled = true
    ttl     = 3600
  }
}

Complete EC2 Example

Okay, let's put it all together and build something real — a web server with a security group and an Elastic IP:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

# Security group allowing SSH and HTTP
resource "aws_security_group" "web_sg" {
  name        = "web-security-group"
  description = "Allow SSH and HTTP"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "web-sg"
  }
}

# EC2 instance
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  
  vpc_security_group_ids = [aws_security_group.web_sg.id]

  user_data = <<-EOF
              #!/bin/bash
              yum update -y
              yum install -y httpd
              systemctl start httpd
              systemctl enable httpd
              echo "Hello from Terraform!" > /var/www/html/index.html
              EOF

  tags = {
    Name = "web-server"
  }
}

# Elastic IP
resource "aws_eip" "web_ip" {
  instance = aws_instance.web.id
  domain   = "vpc"

  tags = {
    Name = "web-eip"
  }
}

Notice how resources reference each other:

  • aws_instance.web uses aws_security_group.web_sg.id
  • aws_eip.web_ip uses aws_instance.web.id

Terraform figures out the order automatically. You don't need to say "create the security group first" — it just knows. How cool is that?

Resource Arguments vs Attributes

"Wait, what's the difference between what I write and what I get back?"

Arguments — what you set (inputs):

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"  # You set this
  instance_type = "t2.micro"               # You set this
}

Attributes — what Terraform gives you back (outputs):

# After apply, you can access:
aws_instance.web.id           # Instance ID (computed)
aws_instance.web.public_ip    # Public IP (computed)
aws_instance.web.arn          # ARN (computed)

Arguments are inputs. Attributes are outputs. Some are both (you can set them and read them back).

Meta-Arguments

These are special arguments that work on any resource. Think of them as superpowers.

depends_on

Force an explicit dependency (for when Terraform can't figure it out on its own):

resource "aws_instance" "web" {
  # ...
  depends_on = [aws_s3_bucket.config]
}

Usually not needed — Terraform infers dependencies from references. But sometimes it needs a hint.

count

"What if I need 3 of the same thing?"

Create multiple copies with count:

resource "aws_instance" "web" {
  count         = 3
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  
  tags = {
    Name = "web-${count.index}"  # web-0, web-1, web-2
  }
}

for_each

Create multiple from a map or set — more flexible than count because each resource gets a meaningful key:

resource "aws_instance" "web" {
  for_each      = toset(["web", "api", "worker"])
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  
  tags = {
    Name = each.key  # "web", "api", "worker"
  }
}

lifecycle

Control resource lifecycle:

resource "aws_instance" "web" {
  # ...
  
  lifecycle {
    create_before_destroy = true
    prevent_destroy       = true
    ignore_changes       = [tags]
  }
}

We'll cover these in detail in later chapters. For now just know they exist — they're incredibly useful.

File Organization

"Do I have to put everything in one giant file?"

Nope! You can split config across multiple .tf files:

project/
ā”œā”€ā”€ main.tf          # Main resources
ā”œā”€ā”€ providers.tf     # Provider config
ā”œā”€ā”€ variables.tf     # Variable definitions
ā”œā”€ā”€ outputs.tf       # Output definitions
└── terraform.tfvars # Variable values

Terraform reads ALL .tf files in a directory and combines them. Split for readability, not necessity. Your future self will thank you.

Formatting

Keep code consistent — messy HCL makes everyone grumpy:

# Format all files in current directory
terraform fmt

# Format and show changes
terraform fmt -diff

# Check formatting (useful for CI)
terraform fmt -check

Validation

Check syntax without connecting to provider:

terraform validate
Success! The configuration is valid.

Or:

Error: Missing required argument

  on main.tf line 15, in resource "aws_instance" "web":
  15: resource "aws_instance" "web" {

The argument "ami" is required, but no definition was found.

What's Next?

Nice work! You now understand:

  • HCL block structure (type, labels, body)
  • Resources, providers, and how they connect
  • Data types and meta-arguments like count and for_each
  • File organization that doesn't make you cry

But right now everything is hardcoded — that's not great for reusability. Let's fix that with variables and outputs. Let's go!