HCL Syntax & Resources
Understand Terraform's configuration language and resource blocks
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 Type | What it creates |
|---|---|
aws_instance | EC2 instance |
aws_s3_bucket | S3 bucket |
aws_vpc | VPC |
aws_iam_role | IAM role |
aws_lambda_function | Lambda 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.webusesaws_security_group.web_sg.idaws_eip.web_ipusesaws_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
countandfor_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!