HCL Syntax, Resource Blocks & Meta-Arguments
HCL (HashiCorp Configuration Language) is the human-readable language used to write Terraform configuration. It is composed of blocks, arguments, and expressions. Resource blocks are the core building block for managing infrastructure. Meta-arguments (depends_on, count, for_each, provider, lifecycle) apply to any resource and control how Terraform creates, updates, and destroys it.
1. HCL Fundamentals
HCL (HashiCorp Configuration Language) is the language used to write Terraform configuration. It is:
- Human-readable — designed to be written and understood by humans, not just machines
- Declarative — describes desired state, not steps
- Stored in
.tffiles — all.tffiles in a directory are merged into one configuration - JSON-compatible — valid JSON is valid HCL; use
.tf.jsonfor machine-generated configs
HCL Syntax Rules
1# Single-line comment
2/* Multi-line
3 comment */
4
5# Arguments: key = value
6instance_type = "t3.micro"
7count = 3
8enabled = true
9
10# Blocks: keyword optional_labels { body }
11resource "aws_instance" "web" {
12 ami = "ami-0c55b159cbfafe1f0"
13 instance_type = "t3.micro"
14}
15
16# Nested blocks
17resource "aws_security_group" "web" {
18 name = "web-sg"
19
20 ingress { # nested block (no labels)
21 from_port = 80
22 to_port = 80
23 protocol = "tcp"
24 cidr_blocks = ["0.0.0.0/0"]
25 }
26}
27
28# String interpolation
29name = "web-${var.environment}-server"
30
31# Multi-line string (heredoc)
32user_data = <<-EOT
33 #!/bin/bash
34 apt-get update
35 apt-get install -y nginx
36EOT2. All Block Types
1# 1. terraform block — Terraform settings
2terraform {
3 required_version = ">= 1.5.0"
4 required_providers {
5 aws = { source = "hashicorp/aws", version = "~> 5.0" }
6 }
7 backend "s3" { bucket = "my-state", key = "prod/terraform.tfstate", region = "us-east-1" }
8}
9
10# 2. provider block — Configure a platform plugin
11provider "aws" {
12 region = "us-east-1"
13}
14
15# 3. resource block — Infrastructure object to manage
16resource "aws_instance" "web" {
17 ami = "ami-0c55b159cbfafe1f0"
18 instance_type = "t3.micro"
19}
20
21# 4. data block — Read existing infrastructure (not managed here)
22data "aws_ami" "ubuntu" {
23 most_recent = true
24 owners = ["099720109477"]
25 filter { name = "name", values = ["ubuntu/images/hvm-ssd/ubuntu-*-22.04-amd64-server-*"] }
26}
27
28# 5. variable block — Parameterize configuration
29variable "region" {
30 type = string
31 default = "us-east-1"
32}
33
34# 6. output block — Expose values after apply
35output "instance_ip" {
36 value = aws_instance.web.public_ip
37}
38
39# 7. locals block — Intermediate computed values
40locals {
41 common_tags = { Environment = var.env, ManagedBy = "terraform" }
42}
43
44# 8. module block — Instantiate a reusable module
45module "vpc" {
46 source = "terraform-aws-modules/vpc/aws"
47 version = "~> 5.0"
48 cidr = "10.0.0.0/16"
49}| Block | Labels | Purpose |
|---|---|---|
terraform | None | Terraform version, providers, backend config |
provider | Provider name | Credentials, region, endpoints for a platform |
resource | Type, Name | Infrastructure object to create and manage |
data | Type, Name | Read existing infrastructure not managed by this config |
variable | Name | Input parameter declaration |
output | Name | Expose a value after apply; module return value |
locals | None | Computed intermediate values (no external input) |
module | Name | Instantiate a child module (local or Registry) |
3. The terraform {} Block in Depth
1terraform {
2 # Minimum Terraform version required to run this config
3 required_version = ">= 1.5.0, < 2.0.0"
4
5 # Declare providers and their version constraints
6 required_providers {
7 aws = {
8 source = "hashicorp/aws" # <namespace>/<type> on registry.terraform.io
9 version = "~> 5.0" # >= 5.0.0, < 6.0.0
10 }
11 random = {
12 source = "hashicorp/random"
13 version = ">= 3.1"
14 }
15 }
16
17 # Remote state backend
18 backend "s3" {
19 bucket = "my-terraform-state"
20 key = "prod/terraform.tfstate"
21 region = "us-east-1"
22 dynamodb_table = "terraform-locks"
23 encrypt = true
24 }
25}Version constraint operators:
| Operator | Meaning | Example |
|---|---|---|
= 1.5.0 | Exact version only | Must be exactly 1.5.0 |
!= 1.4.0 | Exclude this version | Any version except 1.4.0 |
> 1.5 | Greater than | 1.5.1, 1.6, 2.0 |
>= 1.5 | Greater than or equal | 1.5.0 and above |
< 2.0 | Less than | 1.x only |
~> 1.5 | Allows only patch updates | ~> 1.5 = >= 1.5, < 2.0; ~> 1.5.0 = >= 1.5.0, < 1.6.0 |
The ~> (pessimistic constraint) is the most common: ~> 5.0 means "5.x but not 6".
4. Resource Blocks — The Core Building Block
Every resource block manages exactly one real-world infrastructure object:
1resource "<PROVIDER>_<TYPE>" "<LOCAL_NAME>" {
2 # Arguments specific to this resource type
3 argument_one = "value"
4 argument_two = 42
5
6 # Nested block
7 nested_block {
8 nested_arg = "value"
9 }
10}<PROVIDER>_<TYPE>— e.g.,aws_instance,google_storage_bucket,azurerm_resource_group<LOCAL_NAME>— arbitrary name you choose; used to reference this resource within the module- Full address:
<PROVIDER>_<TYPE>.<LOCAL_NAME>e.g.,aws_instance.web
1resource "aws_instance" "web" {
2 ami = data.aws_ami.ubuntu.id # reference to a data source
3 instance_type = var.instance_type # reference to a variable
4 subnet_id = module.vpc.public_subnets[0] # reference to module output
5
6 tags = merge(local.common_tags, {
7 Name = "web-${var.environment}"
8 })
9}
10
11# Reference this resource elsewhere: aws_instance.web.id, aws_instance.web.public_ip5. Expression References
1# Resource attribute
2aws_instance.web.id
3aws_instance.web.public_ip
4aws_security_group.web.id
5
6# Variable
7var.region
8var.instance_type
9var.tags["Name"]
10
11# Local value
12local.common_tags
13local.name_prefix
14
15# Data source attribute
16data.aws_ami.ubuntu.id
17data.aws_vpc.default.id
18data.aws_availability_zones.available.names[0]
19
20# Module output
21module.vpc.vpc_id
22module.vpc.public_subnets
23module.vpc.public_subnets[0]
24
25# Self reference (inside provisioner or lifecycle)
26self.public_ip
27self.id
28
29# Path references
30path.module # filesystem path of current module
31path.root # filesystem path of root module
32path.cwd # current working directory
33
34# Terraform meta-values
35terraform.workspace # current workspace name6. Meta-Arguments — Apply to Any Resource
Meta-arguments are special arguments that work on any resource type, regardless of provider. They control how Terraform manages the resource lifecycle.
7. depends_on — Explicit Dependencies
Terraform automatically detects dependencies from references. Use depends_on only when there is a hidden dependency that Terraform cannot detect from the configuration:
1resource "aws_iam_role_policy" "example" {
2 name = "example"
3 role = aws_iam_role.example.id
4 policy = data.aws_iam_policy_document.example.json
5}
6
7resource "aws_instance" "web" {
8 ami = data.aws_ami.ubuntu.id
9 instance_type = "t3.micro"
10
11 # IAM role is attached via instance profile — Terraform can't see this dependency
12 # Without depends_on, instance might launch before the policy is fully propagated
13 depends_on = [aws_iam_role_policy.example]
14}When to use depends_on:
- IAM permission propagation delays
- External systems that need to be ready before a resource is created
- Resources connected via side effects (not direct attribute references)
- Modules that have hidden dependencies on other modules
Important: Over-using depends_on serializes the dependency graph and slows down apply. Only use it when Terraform genuinely cannot detect the dependency.
8. count — Create N Resources
1# Create 3 identical EC2 instances
2resource "aws_instance" "web" {
3 count = 3
4 ami = data.aws_ami.ubuntu.id
5 instance_type = "t3.micro"
6
7 tags = {
8 Name = "web-${count.index}" # count.index = 0, 1, 2
9 }
10}
11
12# Reference by index
13output "instance_ids" {
14 value = aws_instance.web[*].id # splat expression = all IDs as list
15}
16
17# Single instance from count
18resource "aws_eip" "web_0" {
19 instance = aws_instance.web[0].id
20}
21
22# Conditionally create a resource (count 0 or 1)
23resource "aws_cloudwatch_log_group" "app" {
24 count = var.enable_logging ? 1 : 0
25 name = "/app/${var.environment}"
26}count limitations:
- Resources are addressed by index (0, 1, 2...) — if you remove element 0, everything shifts
- Changing
countcan cause unexpected replacements - Use
for_eachwhen resources have meaningful identities
9. for_each — Create Resources from a Map or Set
1# Create one S3 bucket per environment
2resource "aws_s3_bucket" "env_buckets" {
3 for_each = toset(["dev", "staging", "prod"])
4
5 bucket = "my-app-${each.key}"
6
7 tags = {
8 Environment = each.key
9 }
10}
11
12# Reference by key
13output "bucket_arns" {
14 value = { for k, v in aws_s3_bucket.env_buckets : k => v.arn }
15}
16
17# Using a map for more configuration per item
18variable "buckets" {
19 default = {
20 logs = { versioning = false, region = "us-east-1" }
21 backups = { versioning = true, region = "us-west-2" }
22 }
23}
24
25resource "aws_s3_bucket" "app" {
26 for_each = var.buckets
27
28 bucket = "myapp-${each.key}"
29 # each.key = "logs" or "backups"
30 # each.value = { versioning = false, region = "us-east-1" }
31}count vs for_each:
| Aspect | count | for_each |
|---|---|---|
| Indexed by | Integer (0, 1, 2) | String key from map/set |
| Remove middle item | Shifts indices — causes replacements | Removes only that key — no impact on others |
| Best for | Identical copies, conditional resources | Resources with individual identities |
| each.key | N/A | The map key or set element |
| each.value | N/A | The map value for this key |
| Splat | resource.name[*].attr | values(resource.name)[*].attr |
10. provider — Multi-Region and Multi-Account
1# Default provider
2provider "aws" {
3 region = "us-east-1"
4}
5
6# Aliased provider — different region
7provider "aws" {
8 alias = "eu"
9 region = "eu-west-1"
10}
11
12# Use the aliased provider in a resource
13resource "aws_s3_bucket" "eu_backup" {
14 provider = aws.eu # select the aliased provider
15 bucket = "my-eu-backups"
16}
17
18# Multi-account: assume a role in another account
19provider "aws" {
20 alias = "prod"
21 region = "us-east-1"
22 assume_role {
23 role_arn = "arn:aws:iam::123456789012:role/TerraformDeployRole"
24 }
25}11. lifecycle — Control Create/Destroy Behavior
The lifecycle block is the most powerful meta-argument. It controls how Terraform handles resource replacement, deletion, and drift:
1resource "aws_instance" "web" {
2 ami = data.aws_ami.ubuntu.id
3 instance_type = "t3.micro"
4
5 lifecycle {
6 # Create the replacement BEFORE destroying the old one (zero downtime)
7 create_before_destroy = true
8
9 # Never destroy this resource (protects production databases)
10 prevent_destroy = true
11
12 # Ignore changes to these attributes (managed outside Terraform)
13 ignore_changes = [
14 tags["LastModified"],
15 user_data,
16 ami, # ignore AMI updates — managed by auto-scaling
17 ]
18
19 # Trigger replacement when another value changes
20 replace_triggered_by = [
21 aws_launch_template.web.latest_version
22 ]
23 }
24}| lifecycle setting | What it does |
|---|---|
create_before_destroy = true | New resource created first, old destroyed after. Required for resources where downtime must be avoided. |
prevent_destroy = true | Terraform errors if anything tries to destroy this resource. Protects critical resources (RDS, S3). |
ignore_changes = [attr] | Terraform ignores drift in the listed attributes. Useful when other tools modify the resource. |
replace_triggered_by = [ref] | Force resource replacement when a referenced resource or attribute changes. |
create_before_destroy propagates up the dependency graph — if resource A depends on B and B has create_before_destroy, A must also support this behavior.
12. Putting It All Together — Real-World Example
1terraform {
2 required_version = ">= 1.5.0"
3 required_providers {
4 aws = { source = "hashicorp/aws", version = "~> 5.0" }
5 }
6}
7
8provider "aws" {
9 region = var.aws_region
10}
11
12variable "aws_region" { type = string; default = "us-east-1" }
13variable "environment" { type = string }
14variable "instance_count"{ type = number; default = 2 }
15
16locals {
17 common_tags = {
18 Environment = var.environment
19 ManagedBy = "terraform"
20 Project = "web-app"
21 }
22}
23
24data "aws_ami" "ubuntu" {
25 most_recent = true
26 owners = ["099720109477"]
27 filter { name = "name"; values = ["ubuntu/images/hvm-ssd/ubuntu-*-22.04-amd64-server-*"] }
28}
29
30resource "aws_instance" "web" {
31 count = var.instance_count
32 ami = data.aws_ami.ubuntu.id
33 instance_type = "t3.micro"
34
35 tags = merge(local.common_tags, { Name = "web-${var.environment}-${count.index}" })
36
37 lifecycle {
38 create_before_destroy = true
39 ignore_changes = [ami]
40 }
41}
42
43output "instance_public_ips" {
44 description = "Public IPs of all web instances"
45 value = aws_instance.web[*].public_ip
46}13. Quick Reference
| Concept | Key Fact |
|---|---|
| HCL file extension | .tf (or .tf.json for JSON-format) |
All .tf in a dir | Merged into one configuration (order doesn't matter) |
| Resource address | <type>.<name> e.g., aws_instance.web |
| Attribute reference | aws_instance.web.public_ip |
~> 5.0 constraint | Allows 5.x but not 6.0 — most common pattern |
depends_on | Explicit ordering for hidden dependencies only |
count | N identical resources; indexed by integer |
for_each | One resource per map/set element; indexed by key |
| count vs for_each | for_each is safer when removing items — no index shifting |
provider meta-arg | Select aliased provider for multi-region or multi-account |
create_before_destroy | New resource first, then destroy old — zero-downtime replacements |
prevent_destroy | Terraform errors if this resource would be destroyed |
ignore_changes | Ignore drift on listed attributes |
replace_triggered_by | Force replacement when referenced value changes |
| Splat expression | aws_instance.web[*].id — all IDs from a count resource |
Practice Questions9
Q1. Consider the following Terraform configuration. What will `terraform apply` create? ```hcl resource "aws_s3_bucket" "logs" { bucket = "my-app-logs-bucket" tags = { Environment = "production" } } ```
Select one answer before revealing.
Q2. In Terraform, what is the purpose of the `depends_on` meta-argument?
Select one answer before revealing.
Q3. What does the following Terraform block create? ```hcl data "aws_ami" "amazon_linux" { most_recent = true owners = ["amazon"] filter { name = "name" values = ["amzn2-ami-hvm-*-x86_64-gp2"] } } ```
Select one answer before revealing.
Q4. How do you reference the `id` attribute of an `aws_vpc` resource named `main` in another resource?
Select one answer before revealing.
Q5. What is the `count` meta-argument used for in Terraform?
Select one answer before revealing.
Q6. What is the purpose of the `terraform` block's `required_version` constraint?
Select one answer before revealing.
Q7. What is the correct way to define a Terraform module that creates an optional resource (only created when a variable is `true`)?
Select one answer before revealing.
Q8. Scenario: You run `terraform plan` and see 47 resources marked for replacement (`-/+`). You only changed one variable (`var.environment = "prod"`). What is the most likely explanation?
Select one answer before revealing.
Q9. Hands-On: The following Terraform configuration fails to apply. Identify the error. ```hcl resource "aws_instance" "web" { count = 3 ami = "ami-0c55b159cbfafe1f0" instance_type = "t3.micro" } resource "aws_eip" "web" { instance = aws_instance.web.id # ← Issue is here } ```
Select one answer before revealing.