HCL Basics
Terraform uses HCL (HashiCorp Configuration Language) — a declarative language designed for infrastructure. Files end in .tf.
Resources
Section titled “Resources”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 (Input)
Section titled “Variables (Input)”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}Passing Values
Section titled “Passing Values”terraform apply -var="instance_type=t3.large"terraform apply -var-file="prod.tfvars"Or in a .tfvars file:
instance_type = "t3.large"replicas = 3enable_monitoring = trueVariable Precedence (Lowest to Highest)
Section titled “Variable Precedence (Lowest to Highest)”defaultin the variable block- Environment variables (
TF_VAR_instance_type) terraform.tfvarsor*.auto.tfvarsfiles-var-fileflag-varflag on the command line
Sensitive Variables
Section titled “Sensitive Variables”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
Section titled “Outputs”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
Section titled “Locals”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
Section titled “Data Sources”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 IDaws_region— Current regionaws_vpc— Look up an existing VPCazurerm_resource_group— Look up an existing resource group
Type Constraints
Section titled “Type Constraints”Variables support type constraints:
| Type | Example |
|---|---|
string | "hello" |
number | 42, 3.14 |
bool | true, 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" }, ]}Expressions
Section titled “Expressions”String Interpolation
Section titled “String Interpolation”name = "server-${var.environment}-${count.index}"Conditional
Section titled “Conditional”instance_type = var.environment == "production" ? "t3.large" : "t3.micro"For Expressions
Section titled “For Expressions”Transform collections:
# List of names from a list of objectssubnet_names = [for s in var.subnets : s.name]
# Map from name to CIDRsubnet_map = { for s in var.subnets : s.name => s.cidr }
# Filterprod_subnets = [for s in var.subnets : s if s.name != "dev"]Splat Expressions
Section titled “Splat Expressions”Shorthand for extracting attributes from a list:
instance_ids = aws_instance.web[*].idEquivalent to [for i in aws_instance.web : i.id].
count and for_each
Section titled “count and for_each”Create multiple instances of a resource:
# count — simple numericresource "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.
lifecycle Meta-Arguments
Section titled “lifecycle Meta-Arguments”The lifecycle block controls how Terraform handles resource creation, updates, and deletion:
create_before_destroy
Section titled “create_before_destroy”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.
prevent_destroy
Section titled “prevent_destroy”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.
ignore_changes
Section titled “ignore_changes”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}replace_triggered_by
Section titled “replace_triggered_by”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.
Combining lifecycle Rules
Section titled “Combining lifecycle Rules”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 }}depends_on and Resource Dependencies
Section titled “depends_on and Resource Dependencies”Implicit Dependencies (Automatic)
Section titled “Implicit Dependencies (Automatic)”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.
Explicit Dependencies (depends_on)
Section titled “Explicit Dependencies (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]}When to Use depends_on
Section titled “When to Use depends_on”- 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 Not to Use depends_on
Section titled “When Not to Use depends_on”- When there’s already an attribute reference — the implicit dependency is enough.
- As a general “just to be safe” measure — unnecessary
depends_onslows down plans and makes refactoring harder.
# BAD — depends_on is redundant hereresource "aws_subnet" "public" { vpc_id = aws_vpc.main.id # already creates implicit dependency depends_on = [aws_vpc.main] # unnecessary}depends_on With Modules
Section titled “depends_on With Modules”module "app" { source = "./modules/app" subnet_id = module.vpc.public_subnet_ids[0]
depends_on = [module.iam] # app module needs IAM to be ready}Key Takeaways
Section titled “Key Takeaways”- 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_eachovercountfor collections — it avoids unintended resource recreation. - String interpolation, conditionals, and for expressions keep your config DRY.
- Use
lifecycleto 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_onfor behavioral dependencies that Terraform can’t infer.