Fatskills
Practice. Master. Repeat.
Study Guide: TECH **Terraform HCL Syntax: Blocks, Arguments, Expressions – Zero-Fluff Study Guide**
Source: https://www.fatskills.com/cloud-application-developer/chapter/tech-terraform-hcl-syntax-blocks-arguments-expressions-zero-fluff-study-guide

TECH **Terraform HCL Syntax: Blocks, Arguments, Expressions – Zero-Fluff Study Guide**

By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.

⏱️ ~9 min read

Terraform HCL Syntax: Blocks, Arguments, Expressions – Zero-Fluff Study Guide

For engineers who need to write, debug, and scale Terraform in production—fast.


1. What This Is & Why It Matters

HCL (HashiCorp Configuration Language) is Terraform’s native syntax. Blocks, arguments, and expressions are the DNA of your infrastructure code. If you don’t master them: - You’ll write brittle, unmaintainable Terraform that breaks in CI/CD.
- You’ll waste hours debugging "invalid argument" errors because you misplaced a = or forgot a comma.
- You’ll struggle to refactor legacy codebases (e.g., "Why does this aws_instance block have 50 arguments?").

Real-world scenario:
You inherit a Terraform repo where a single main.tf file deploys 20 resources with hardcoded values. Your task: modularize it to support multi-region, multi-environment deployments (dev/stage/prod). Without understanding HCL syntax, you’ll either: - Break existing deployments by misplacing a block.
- Duplicate code instead of using variables/expressions.
- Fail to enforce security policies (e.g., "All S3 buckets must have encryption enabled").

This guide gives you the exact syntax and patterns to write clean, scalable Terraform—today.


2. Core Concepts & Components


? Block

  • Definition: A container for configuration (like a "paragraph" in HCL). Starts with a type (e.g., resource, variable) and a label.
    hcl resource "aws_instance" "web" { # Block type: "resource", labels: "aws_instance", "web"
    ami = "ami-123456" # Arguments inside the block
    instance_type = "t2.micro" }
  • Production insight:
  • Blocks define scope. A resource block creates infrastructure; a module block reuses code.
  • Nesting matters: dynamic blocks (covered later) let you loop over lists/maps to avoid copy-pasting.

? Argument

  • Definition: A key-value pair inside a block (e.g., ami = "ami-123456").
  • Required arguments: Must be set (e.g., ami for aws_instance).
  • Optional arguments: Have defaults (e.g., instance_type = "t2.micro").
  • Production insight:
  • Hardcoding values is a trap. Use variables (var.ami_id) or data sources (data.aws_ami.latest.id) instead.
  • Sensitive arguments: Mark secrets with sensitive = true to hide them in logs:
    hcl
    variable "db_password" {
    type = string
    sensitive = true
    }

? Expression

  • Definition: A value or computation (e.g., "${var.env}-bucket", count.index, aws_instance.web.private_ip).
  • Types:
    • Literals: "us-east-1", 42, true.
    • References: var.env, aws_instance.web.id.
    • Operators: +, -, ==, &&, !.
    • Functions: concat(), lookup(), file().
    • Interpolation: "${var.env}-bucket" (legacy) or var.env (modern).
  • Production insight:
  • Expressions enable DRY (Don’t Repeat Yourself) code. Example:
    hcl
    tags = {
    Name = "${var.env}-app"
    Environment = var.env
    }
  • Complex expressions can hurt readability. Break them into local values:
    hcl
    locals {
    bucket_name = "${var.env}-${var.project}-bucket"
    }
    resource "aws_s3_bucket" "app" {
    bucket = local.bucket_name
    }

? Dynamic Block

  • Definition: A block that generates nested configurations dynamically (e.g., security group rules from a list).
    hcl dynamic "ingress" {
    for_each = var.ingress_ports
    content {
    from_port = ingress.value
    to_port = ingress.value
    protocol = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    } }
  • Production insight:
  • Avoid overusing dynamic blocks. They can make code harder to debug. Use them for repetitive patterns (e.g., IAM policies, security group rules).
  • Combine with for_each to loop over maps:
    hcl
    dynamic "tag" {
    for_each = var.tags
    content {
    key = tag.key
    value = tag.value
    }
    }

? Meta-Arguments

  • Definition: Special arguments that modify Terraform behavior (e.g., count, for_each, depends_on).
  • count: Creates multiple instances of a resource.
    hcl
    resource "aws_instance" "web" {
    count = 3
    ami = "ami-123456"
    }
  • for_each: Creates resources from a map/set (better than count for unique names).
    hcl
    resource "aws_iam_user" "developers" {
    for_each = toset(["alice", "bob", "charlie"])
    name = each.key
    }
  • depends_on: Explicitly sets dependencies (use sparingly).
    hcl
    resource "aws_db_instance" "app" {
    depends_on = [aws_security_group.db]
    }
  • Production insight:
  • count vs. for_each:
    • Use count for identical resources (e.g., 3 identical EC2 instances).
    • Use for_each for unique resources (e.g., IAM users with different names).
  • depends_on is a code smell. Terraform usually infers dependencies. Use it only for hidden dependencies (e.g., IAM policies that must exist before a Lambda function).

? Data Sources

  • Definition: Reads existing infrastructure (e.g., data.aws_ami.latest).
    hcl data "aws_ami" "ubuntu" {
    most_recent = true
    owners = ["099720109477"] # Canonical
    filter {
    name = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
    } }
  • Production insight:
  • Avoid hardcoding IDs. Use data sources to fetch AMIs, VPCs, or subnets dynamically.
  • Data sources can fail. Add depends_on if they rely on other resources.

? Locals

  • Definition: Named expressions for reuse (like "variables for intermediate values").
    hcl locals {
    common_tags = {
    Project = "my-app"
    Environment = var.env
    } } resource "aws_instance" "web" {
    tags = local.common_tags }
  • Production insight:
  • Use locals to avoid repetition. Example: Compute a subnet CIDR block:
    hcl
    locals {
    subnet_cidr = cidrsubnet(var.vpc_cidr, 8, 1)
    }
  • Locals are evaluated at plan time. They can’t reference resources that don’t exist yet.

? Outputs

  • Definition: Exposes values to the CLI or other modules (e.g., output "instance_ip").
    hcl output "instance_ip" {
    value = aws_instance.web.private_ip }
  • Production insight:
  • Outputs are critical for debugging. Example: Print the load balancer DNS name:
    hcl
    output "alb_dns" {
    value = aws_lb.app.dns_name
    }
  • Sensitive outputs: Mark them with sensitive = true to hide them in logs.


3. Step-by-Step Hands-On: Deploy a Secure S3 Bucket with Dynamic Policies

Task: Deploy an S3 bucket with: - A unique name (using variables).
- Encryption enabled.
- A dynamic IAM policy (based on a variable list of users).
- Outputs for the bucket name and ARN.

Prerequisites

  1. AWS account with admin IAM permissions.
  2. Terraform installed (terraform --version).
  3. AWS CLI configured (aws sts get-caller-identity).

Steps

  1. Initialize the project:
    bash
    mkdir tf-s3-demo && cd tf-s3-demo
    touch main.tf variables.tf outputs.tf

  2. Define variables (variables.tf):
    ```hcl
    variable "bucket_name" {
    type = string
    description = "Name of the S3 bucket (must be globally unique)"
    }

variable "allowed_users" {
type = list(string)
description = "List of IAM user ARNs allowed to access the bucket"
default = []
}

variable "env" {
type = string
description = "Environment (dev/stage/prod)"
default = "dev"
}
```


  1. Write the main configuration (main.tf):
    ```hcl
    # Create the bucket
    resource "aws_s3_bucket" "app" {
    bucket = "${var.env}-${var.bucket_name}"
    tags = {
    Environment = var.env
    }
    }

# Enable encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "app" {
bucket = aws_s3_bucket.app.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}

# Dynamic IAM policy for allowed users
data "aws_iam_policy_document" "bucket_policy" {
statement {
actions = ["s3:GetObject", "s3:PutObject"]
resources = ["${aws_s3_bucket.app.arn}/*"]
principals {
type = "AWS"
identifiers = var.allowed_users
}
}
}

resource "aws_s3_bucket_policy" "app" {
bucket = aws_s3_bucket.app.id
policy = data.aws_iam_policy_document.bucket_policy.json
}
```


  1. Define outputs (outputs.tf):
    ```hcl
    output "bucket_name" {
    value = aws_s3_bucket.app.bucket
    }

output "bucket_arn" {
value = aws_s3_bucket.app.arn
}
```


  1. Deploy the infrastructure:
    bash
    terraform init
    terraform plan -var="bucket_name=my-unique-bucket-123" -var="allowed_users=[\"arn:aws:iam::123456789012:user/alice\"]"
    terraform apply -var="bucket_name=my-unique-bucket-123" -var="allowed_users=[\"arn:aws:iam::123456789012:user/alice\"]"

  2. Verify the deployment:

  3. Check the outputs:
    bash
    terraform output
  4. Log in as alice and test access:
    bash
    aws s3 ls s3://dev-my-unique-bucket-123

  5. Clean up:
    bash
    terraform destroy -var="bucket_name=my-unique-bucket-123"


4. ? Production-Ready Best Practices


Security

  • Never hardcode secrets. Use sensitive = true for variables and outputs.
  • Least privilege: Use aws_iam_policy_document to generate minimal IAM policies.
  • Encrypt everything: Enable S3 encryption, RDS encryption, and EBS encryption by default.

Cost Optimization

  • Use count/for_each to avoid over-provisioning. Example: Only create a NAT gateway if var.env == "prod".
  • Tag resources for cost allocation. Example: hcl tags = {
    CostCenter = "marketing" }

Reliability & Maintainability

  • Modularize code. Split large main.tf files into:
  • network.tf (VPC, subnets, security groups).
  • compute.tf (EC2, ECS, Lambda).
  • storage.tf (S3, RDS, DynamoDB).
  • Use locals for complex expressions. Example: hcl locals {
    subnet_cidrs = [for i in range(3) : cidrsubnet(var.vpc_cidr, 8, i)] }
  • Enforce naming conventions. Example: hcl resource "aws_s3_bucket" "app" {
    bucket = "${var.env}-${var.project}-bucket" }

Observability

  • Output critical values. Example: hcl output "alb_dns" {
    value = aws_lb.app.dns_name }
  • Tag resources for monitoring. Example: hcl tags = {
    Monitoring = "enabled" }


5. ⚠️ Common Mistakes & Traps

Mistake Symptom Fix/Prevention
Hardcoding values Terraform fails in different environments. Use variables (var.env) and data sources (data.aws_ami.latest).
Misusing count for unique resources Terraform destroys/recreates resources. Use for_each for unique resources (e.g., IAM users).
Forgetting depends_on Terraform fails with "resource not found". Let Terraform infer dependencies. Use depends_on only for hidden dependencies.
Overusing dynamic blocks Code becomes unreadable. Use dynamic only for repetitive patterns (e.g., security group rules).
Not using locals Repeated expressions in multiple places. Extract complex expressions into locals.


6. ? Exam/Certification Focus


Typical Question Patterns

  1. Block vs. Argument:
  2. "Which of the following is a block in HCL?"


    • resource "aws_instance" "web" { ... }
    • ami = "ami-123456" (this is an argument).
  3. Meta-Arguments:

  4. "You need to create 3 identical EC2 instances. Which meta-argument should you use?"


    • count
    • for_each (better for unique resources).
  5. Expressions:

  6. "Which expression correctly references a variable?"


    • var.env
    • "${var.env}" (legacy syntax).
  7. Dynamic Blocks:

  8. "How do you create multiple security group rules from a list?"
    • dynamic "ingress" { for_each = var.ports ... }
    • ❌ Copy-paste the ingress block 3 times.

Key Trap Distinctions

  • count vs. for_each:
  • count uses numeric indices (aws_instance.web[0]).
  • for_each uses keys (aws_instance.web["alice"]).
  • Interpolation:
  • Legacy: "${var.env}-bucket".
  • Modern: var.env (no ${} needed).


7. ? Hands-On Challenge

Task: Write a Terraform configuration that: 1. Creates an IAM user for each name in var.users = ["alice", "bob"].
2. Attaches a policy allowing s3:GetObject on a bucket named ${var.env}-app-bucket.
3. Outputs the IAM user ARNs.

Solution:


variable "users" {
  type    = list(string)
  default = ["alice", "bob"]
}

variable "env" {
  type    = string
  default = "dev"
}

resource "aws_iam_user" "app" {
  for_each = toset(var.users)
  name     = each.key
}

resource "aws_iam_policy" "s3_read" {
  name        = "${var.env}-s3-read-policy"
  description = "Allow S3 read access"
  policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["s3:GetObject"]
Effect = "Allow"
Resource = "arn:aws:s3:::${var.env}-app-bucket/*"
}
] }) } resource "aws_iam_user_policy_attachment" "s3_read" { for_each = aws_iam_user.app user = each.key policy_arn = aws_iam_policy.s3_read.arn } output "user_arns" { value = [for user in aws_iam_user.app : user.arn] }

Why it works:
- for_each creates one IAM user per name in var.users.
- The policy is attached to each user dynamically.
- The output uses a for expression to list all ARNs.


8. ? Rapid-Reference Crib Sheet

Concept Syntax Example Key Notes
Block resource "aws_s3_bucket" "app" { ... } Labels must be unique per block type.
Argument ami = "ami-123456" Required vs. optional (check docs).
Expression var.env, aws_instance.web.id Use locals for complex expressions.
Dynamic Block dynamic "ingress" { for_each = var.ports } Avoid overuse—can hurt readability.
Meta-Argument count = 3, for_each = var.users for_each > count for unique resources.
Data Source data "aws_ami" "ubuntu" { ... } Fetches existing infrastructure.
Local locals { name = "${var.env}-bucket" } Evaluated at plan time.
Output output "ip" { value = aws_instance.web.ip } Use sensitive = true for secrets.
⚠️ Interpolation "${var.env}" (legacy) → var.env (modern) Modern Terraform doesn’t need ${}.
⚠️ count vs. for_each count = 3 vs. for_each = toset(["a", "b"]) for_each is safer for unique resources.


9. ? Where to Go Next

  1. Terraform HCL Documentation – Official syntax reference.
  2. Terraform Best Practices – Real-world patterns.
  3. [AWS Provider Docs](https://registry.terraform.io/providers/hashic


ADVERTISEMENT