/HCL Syntax, Resource Blocks & Meta-Arguments
Concept
Easy

HCL Syntax, Resource Blocks & Meta-Arguments

10 min read·hclresource-blocksmeta-argumentslifecycledepends-oncountfor-eachexpressionsreferencesterraform-blockterraform-associate

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 .tf files — all .tf files in a directory are merged into one configuration
  • JSON-compatible — valid JSON is valid HCL; use .tf.json for machine-generated configs

HCL Syntax Rules

hcl
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
36EOT

2. All Block Types

hcl
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}
BlockLabelsPurpose
terraformNoneTerraform version, providers, backend config
providerProvider nameCredentials, region, endpoints for a platform
resourceType, NameInfrastructure object to create and manage
dataType, NameRead existing infrastructure not managed by this config
variableNameInput parameter declaration
outputNameExpose a value after apply; module return value
localsNoneComputed intermediate values (no external input)
moduleNameInstantiate a child module (local or Registry)

3. The terraform {} Block in Depth

hcl
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:

OperatorMeaningExample
= 1.5.0Exact version onlyMust be exactly 1.5.0
!= 1.4.0Exclude this versionAny version except 1.4.0
> 1.5Greater than1.5.1, 1.6, 2.0
>= 1.5Greater than or equal1.5.0 and above
< 2.0Less than1.x only
~> 1.5Allows 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:

hcl
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
hcl
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_ip

5. Expression References

hcl
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 name

6. 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.

Rendering diagram…

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:

hcl
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

hcl
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 count can cause unexpected replacements
  • Use for_each when resources have meaningful identities

9. for_each — Create Resources from a Map or Set

hcl
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:

Aspectcountfor_each
Indexed byInteger (0, 1, 2)String key from map/set
Remove middle itemShifts indices — causes replacementsRemoves only that key — no impact on others
Best forIdentical copies, conditional resourcesResources with individual identities
each.keyN/AThe map key or set element
each.valueN/AThe map value for this key
Splatresource.name[*].attrvalues(resource.name)[*].attr

10. provider — Multi-Region and Multi-Account

hcl
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:

hcl
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 settingWhat it does
create_before_destroy = trueNew resource created first, old destroyed after. Required for resources where downtime must be avoided.
prevent_destroy = trueTerraform 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

hcl
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

ConceptKey Fact
HCL file extension.tf (or .tf.json for JSON-format)
All .tf in a dirMerged into one configuration (order doesn't matter)
Resource address<type>.<name> e.g., aws_instance.web
Attribute referenceaws_instance.web.public_ip
~> 5.0 constraintAllows 5.x but not 6.0 — most common pattern
depends_onExplicit ordering for hidden dependencies only
countN identical resources; indexed by integer
for_eachOne resource per map/set element; indexed by key
count vs for_eachfor_each is safer when removing items — no index shifting
provider meta-argSelect aliased provider for multi-region or multi-account
create_before_destroyNew resource first, then destroy old — zero-downtime replacements
prevent_destroyTerraform errors if this resource would be destroyed
ignore_changesIgnore drift on listed attributes
replace_triggered_byForce replacement when referenced value changes
Splat expressionaws_instance.web[*].id — all IDs from a count resource

Practice Questions9

easy

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.

medium

Q2. In Terraform, what is the purpose of the `depends_on` meta-argument?


Select one answer before revealing.

medium

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.

easy

Q4. How do you reference the `id` attribute of an `aws_vpc` resource named `main` in another resource?


Select one answer before revealing.

medium

Q5. What is the `count` meta-argument used for in Terraform?


Select one answer before revealing.

medium

Q6. What is the purpose of the `terraform` block's `required_version` constraint?


Select one answer before revealing.

hard

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.

hard

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.

hard

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.