Skip to content

HCL Basics

First PublishedLast UpdatedByAtif Alam

Terraform uses HCL (HashiCorp Configuration Language) — a declarative language designed for infrastructure. Files end in .tf.

A resource block creates a piece of infrastructure:

resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server"
Env = "production"
}
}
  • "aws_instance" — The resource type (provider prefix + resource name).
  • "web" — The local name you use to reference this resource elsewhere.
  • The body contains arguments specific to that resource type.

You reference this resource’s attributes with aws_instance.web.id, aws_instance.web.public_ip, etc.

Variables let you parameterize your config:

variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "replicas" {
description = "Number of instances"
type = number
default = 1
}
variable "enable_monitoring" {
description = "Enable detailed monitoring"
type = bool
default = false
}

Use them with var.<name>:

resource "aws_instance" "web" {
instance_type = var.instance_type
monitoring = var.enable_monitoring
}
Terminal window
terraform apply -var="instance_type=t3.large"
terraform apply -var-file="prod.tfvars"

Or in a .tfvars file:

prod.tfvars
instance_type = "t3.large"
replicas = 3
enable_monitoring = true
  1. default in the variable block
  2. Environment variables (TF_VAR_instance_type)
  3. terraform.tfvars or *.auto.tfvars files
  4. -var-file flag
  5. -var flag on the command line

For variables that hold secrets (passwords, API keys), set sensitive = true. Terraform will redact the value in plan and apply output — useful when passing secrets via TF_VAR_* or -var. The value is still stored in state; see State — Sensitive Data for protecting state and marking outputs as sensitive.

variable "db_password" {
description = "Database password"
type = string
sensitive = true
}

Outputs expose values after apply — useful for passing info between modules or displaying results:

output "instance_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "instance_id" {
value = aws_instance.web.id
}

After terraform apply:

Outputs:
instance_id = "i-0abc123def456789"
instance_ip = "54.123.45.67"

You can also query outputs with terraform output instance_ip.

Locals define computed or reused values within a configuration — like constants or derived values:

locals {
env = "production"
name_prefix = "myapp-${local.env}"
common_tags = {
Environment = local.env
ManagedBy = "terraform"
Project = "my-project"
}
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = var.instance_type
tags = merge(local.common_tags, { Name = "${local.name_prefix}-web" })
}

Use local.<name> (not locals).

Data sources read existing infrastructure (they don’t create anything):

data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
}

Common data sources:

  • aws_caller_identity — Current account ID
  • aws_region — Current region
  • aws_vpc — Look up an existing VPC
  • azurerm_resource_group — Look up an existing resource group

Variables support type constraints:

TypeExample
string"hello"
number42, 3.14
booltrue, false
list(string)["a", "b", "c"]
map(string){ key = "value" }
set(string)Unique, unordered collection
object({...})object({ name = string, port = number })
tuple([...])tuple([string, number, bool])

Example with complex types:

variable "subnets" {
type = list(object({
name = string
cidr = string
az = string
}))
default = [
{ name = "public-1", cidr = "10.0.1.0/24", az = "us-east-1a" },
{ name = "public-2", cidr = "10.0.2.0/24", az = "us-east-1b" },
]
}
name = "server-${var.environment}-${count.index}"
instance_type = var.environment == "production" ? "t3.large" : "t3.micro"

Transform collections:

# List of names from a list of objects
subnet_names = [for s in var.subnets : s.name]
# Map from name to CIDR
subnet_map = { for s in var.subnets : s.name => s.cidr }
# Filter
prod_subnets = [for s in var.subnets : s if s.name != "dev"]

Shorthand for extracting attributes from a list:

instance_ids = aws_instance.web[*].id

Equivalent to [for i in aws_instance.web : i.id].

Create multiple instances of a resource:

# count — simple numeric
resource "aws_instance" "web" {
count = var.replicas
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = { Name = "web-${count.index}" }
}
# for_each — map or set (preferred, avoids index shifting)
resource "aws_instance" "web" {
for_each = toset(["web-1", "web-2", "web-3"])
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = { Name = each.key }
}

for_each is generally preferred over count because adding/removing items doesn’t cause index shifts that force recreation of unrelated resources.

The lifecycle block controls how Terraform handles resource creation, updates, and deletion:

Create the replacement resource before destroying the old one. Essential for zero-downtime updates:

resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
lifecycle {
create_before_destroy = true
}
}

Without this, Terraform destroys the old instance first, causing downtime. With it, the new instance is up before the old one is removed.

Block any plan that would destroy this resource. Use it for critical resources like databases:

resource "aws_db_instance" "primary" {
engine = "postgres"
instance_class = "db.r6g.large"
lifecycle {
prevent_destroy = true
}
}

If you try to destroy this (directly or by removing it from config), Terraform errors instead of proceeding. To actually destroy it, you must first remove the prevent_destroy rule.

Tell Terraform to ignore changes to specific attributes. Useful when something outside Terraform modifies a field (e.g. auto-scaling changes the desired count):

resource "aws_autoscaling_group" "app" {
desired_capacity = 3
min_size = 1
max_size = 10
lifecycle {
ignore_changes = [desired_capacity]
}
}

Terraform won’t try to reset desired_capacity back to 3 if auto-scaling has changed it to 7.

You can also ignore all attributes:

lifecycle {
ignore_changes = all
}

Force a resource to be replaced when another resource or attribute changes:

resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
lifecycle {
replace_triggered_by = [
aws_security_group.web_sg.id,
]
}
}

If the security group is recreated (its ID changes), the instance will also be replaced.

resource "aws_db_instance" "primary" {
engine = "postgres"
instance_class = "db.r6g.large"
password = var.db_password
lifecycle {
prevent_destroy = true
ignore_changes = [password] # password managed externally
}
}

When one resource references another’s attribute, Terraform infers the dependency automatically:

resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # implicit dependency on aws_vpc.main
cidr_block = "10.0.1.0/24"
}

Terraform creates the VPC first, then the subnet. On destroy, it reverses the order. This covers most cases — you rarely need depends_on.

Use depends_on when there’s a dependency that Terraform can’t infer from attribute references:

resource "aws_iam_role_policy" "app" {
role = aws_iam_role.app.name
policy = data.aws_iam_policy_document.app.json
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
# The instance needs the IAM policy to be in place,
# but doesn't reference any attribute from it
depends_on = [aws_iam_role_policy.app]
}
  • The dependency is behavioral, not through an attribute reference (e.g. “this instance needs that IAM policy to exist, but doesn’t reference it”).
  • A module depends on another module’s side effects.
  • Ordering between resources that don’t directly reference each other.
  • When there’s already an attribute reference — the implicit dependency is enough.
  • As a general “just to be safe” measure — unnecessary depends_on slows down plans and makes refactoring harder.
# BAD — depends_on is redundant here
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # already creates implicit dependency
depends_on = [aws_vpc.main] # unnecessary
}
module "app" {
source = "./modules/app"
subnet_id = module.vpc.public_subnet_ids[0]
depends_on = [module.iam] # app module needs IAM to be ready
}

  • Resources create infrastructure; data sources read existing infrastructure.
  • Variables parameterize config; outputs expose results; locals hold computed values.
  • Use type constraints to catch mistakes early.
  • Prefer for_each over count for collections — it avoids unintended resource recreation.
  • String interpolation, conditionals, and for expressions keep your config DRY.
  • Use lifecycle to control creation order (create_before_destroy), protect critical resources (prevent_destroy), and handle external changes (ignore_changes).
  • Let implicit dependencies (attribute references) handle ordering. Only use depends_on for behavioral dependencies that Terraform can’t infer.