03 · HCL Fundamentals
HCL (HashiCorp Configuration Language) is Terraform's configuration language. It is not a programming language — there are no loops in the traditional sense, no classes, no functions you define. It is a declarative configuration language with just enough expressiveness to make infrastructure configs DRY and composable.
This post covers the four building blocks you'll use in every Terraform configuration.
Resources
A resource is a declaration that a piece of infrastructure should exist with certain properties.
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server"
Environment = "production"
}
}
The resource address (aws_instance.web) is how you refer to this resource elsewhere in the configuration. Attributes of the resource are accessed with dot notation: aws_instance.web.id, aws_instance.web.public_ip.
Variables
Variables are the inputs to a Terraform configuration. They decouple values that change between environments from the structure of the configuration.
variable "instance_type" {
type = string
description = "EC2 instance type"
default = "t3.micro"
}
variable "allowed_cidrs" {
type = list(string)
default = ["10.0.0.0/8"]
}
Variable values are supplied at apply time via:
- -var="instance_type=t3.large" (command line)
- A terraform.tfvars file (auto-loaded)
- Environment variables (TF_VAR_instance_type)
- Terraform Cloud workspace variables
Type constraints are documentation
Always declare a type for your variables. type = string vs type = list(string) vs type = object({ ... }) catches configuration errors early and documents the expected shape of the input.
Outputs
Outputs expose values from a Terraform configuration — either for display after terraform apply or for use by other configurations (via remote state).
output "instance_ip" {
value = aws_instance.web.public_ip
description = "Public IP of the web server"
}
output "db_password" {
value = random_password.db.result
sensitive = true # masked in terminal output, but stored in state
}
Outputs are the interface of a module or a root configuration. Well-designed outputs make it easy to connect configurations together without duplicating resource references.
Locals
Locals are intermediate values computed within the configuration. They reduce duplication and name complex expressions.
locals {
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
name_prefix = "${var.project_name}-${var.environment}"
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web"
})
}
Locals are evaluated once and reused. Unlike variables, they are not inputs — they cannot be set from outside the configuration.
Data Sources
Data sources read existing infrastructure that Terraform does not manage. They are the "read-only" counterpart to resources.
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-*-22.04-amd64-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id # reference the looked-up AMI ID
...
}
Data sources are evaluated during the plan phase. They let you reference IDs and attributes of resources created outside your configuration — VPCs created manually, AMIs published by a vendor, DNS zones managed elsewhere.
The Dependency Graph
Terraform automatically determines the order of operations by analysing references between resources.
resource "aws_vpc" "main" { ... }
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # reference creates an implicit dependency
...
}
Because aws_subnet.public references aws_vpc.main.id, Terraform knows the VPC must be created first. This is an implicit dependency — no explicit declaration needed.
For cases where there is a dependency that isn't expressed through attribute references, use depends_on:
Use depends_on sparingly. When it's needed, it's a sign that the dependency is operational (e.g., a policy must exist before a service starts) rather than structural. Document why.
Count and for_each
Two meta-arguments for creating multiple instances of a resource:
# count: simple numeric repetition
resource "aws_instance" "web" {
count = 3
ami = data.aws_ami.ubuntu.id
tags = { Name = "web-${count.index}" }
}
# for_each: create one resource per map entry
resource "aws_iam_user" "team" {
for_each = toset(["alice", "bob", "carol"])
name = each.value
}
Prefer for_each over count when resources have distinct identities. With count, Terraform identifies resources by index — remove the middle element and Terraform wants to destroy and recreate everything after it. With for_each, resources are identified by key, so removals are targeted.