Learning PathsTerraform
TerraformChapter 3·BEGINNER·10 min read

Configuration Language (HCL) Basics

Blocks, arguments, and expressions from first principles, formatting and style conventions, the real difference between a resource and a data source, and how to design outputs that are actually reusable.

Prerequisites
  • Chapter 1: Foundations & IaC Concepts
  • Chapter 2: Terraform Workflow & CLI
You'll learn to
  • Read and write HCL blocks, arguments, and expressions confidently
  • Apply consistent formatting and commenting conventions
  • Understand a resource's basic lifecycle from the language's point of view
  • Distinguish when a data source is the correct tool instead of a resource
  • Design output blocks that are stable and safe for other configurations to consume

Configuration Language (HCL) Basics

1. Why does this exist?

Chapters 1 and 2 used HCL without really explaining it — you saw resource blocks, provider blocks, arguments. Before going further into variables, functions, and modules, it’s worth stopping to actually name the pieces of the language you’ve been reading by pattern-matching. HCL (HashiCorp Configuration Language) is deliberately simple — a small number of building blocks combine to express almost everything you’ll ever write in Terraform.

2. Concept

HCL has exactly a handful of syntactic building blocks:

Element Shape Example
Block block_type "label1" "label2" { ... } resource "aws_instance" "web" { ... }
Argument name = value instance_type = "t3.micro"
Expression Anything that produces a value "t3." + var.size (string interpolation), var.count > 0

A block’s type (resource, data, variable, output, module, provider, terraform) determines how Terraform interprets it. Most block types take one or more labelsresource takes two (type, then local name); variable and output take one (the name); provider and terraform take zero.

Resources vs Data Sources

resource data
Lifecycle Terraform creates, updates, and destroys it Terraform only reads it — never creates or destroys
Use case “I want this to exist, managed by this configuration” “I need to look up something that already exists elsewhere” (an AMI ID, a VPC created by another team, an existing Route53 zone)
Appears in plan as +, ~, -, or -/+ Only ever a read, no create/change/destroy symbol

A common real pattern: use a data source to look up the latest Amazon Linux AMI, and a resource to launch an EC2 instance using that AMI ID.

3. Architecture Diagram

flowchart LR
    subgraph HCL["HCL File"]
        T[terraform block]
        P[provider block]
        D[data block]
        R[resource block]
        O[output block]
    end
    D -->|referenced by| R
    R -->|referenced by| O
    T -.->|configures| P
    P -.->|used by| D
    P -.->|used by| R

Data flows one direction: data sources feed resources, resources feed outputs. Circular references between resources aren’t possible — this is what lets Terraform build a strict dependency graph (Chapter 8).

4. Visual Explanation

Anatomy of a Resource Block

resource "aws_instance" "web" {
   │           │           │
   │           │           └── local name (how you reference it: aws_instance.web)
   │           └────────────── resource type (defined by the aws provider)
   └────────────────────────── block type

  ami           = data.aws_ami.amazon_linux.id   ← argument referencing a data source
  instance_type = var.instance_type                ← argument referencing a variable
  tags = {                                          ← a nested block-like map argument
    Name = "web-server"
  }
}
Hint: how do I reference this resource elsewhere in my configuration?

Using <resource_type>.<local_name>.<attribute> — e.g. aws_instance.web.id or aws_instance.web.public_ip. The local name (web) is only meaningful within this configuration; it has nothing to do with any real-world name or tag.

5. Example

# Data source: look up the latest Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

# Resource: launch an instance using that AMI
resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"

  tags = {
    Name = "web-server"
  }
}

# Output: expose just the attribute another configuration might need
output "web_public_ip" {
  value       = aws_instance.web.public_ip
  description = "Public IP address of the web server"
}

The data source runs first (it has no dependencies), its .id feeds the resource’s ami argument, and the resource’s .public_ip feeds the output — three blocks, one clean chain of references.

6. Production Example

Style conventions real teams enforce via terraform fmt and code review:

# Good: aligned, one blank line between blocks, snake_case names
resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public.id

  tags = {
    Name        = "web-server"
    Environment = var.environment
  }
}

resource "aws_security_group" "web" {
  name   = "web-sg"
  vpc_id = aws_vpc.main.id
}

terraform fmt handles the alignment automatically — never hand-align = signs, let the tool do it, and run terraform fmt -check in CI so unformatted code fails the build rather than drifting over time.

7. Code Walkthrough — Output Design

# Poor: exposes the entire object, an unintentional and unstable contract
output "web_instance" {
  value = aws_instance.web
}

# Better: expose exactly what consumers need, named clearly
output "web_instance_id" {
  value       = aws_instance.web.id
  description = "ID of the web server instance"
}

output "web_public_ip" {
  value       = aws_instance.web.public_ip
  description = "Public IP of the web server, for DNS record creation"
}

output "db_connection_string" {
  value       = "postgresql://${aws_db_instance.main.username}:REDACTED@${aws_db_instance.main.endpoint}/${aws_db_instance.main.db_name}"
  sensitive   = true
  description = "Connection string for the application to consume — marked sensitive so it never prints in plan/apply logs"
}

sensitive = true doesn’t encrypt the value in state (Chapter 6 covers state encryption properly) — it only suppresses the value from CLI output, which is still an important, cheap safeguard against accidental exposure in CI logs.

8. Behind the Scenes

When Terraform parses your .tf files, every block becomes a node with a computed address (aws_instance.web, data.aws_ami.amazon_linux, output.web_public_ip) in an internal graph. References between blocks (aws_instance.web.ami pointing at data.aws_ami.amazon_linux.id) become graph edges. This is why HCL feels declarative rather than sequential — you’re not writing “first do X, then Y”; you’re describing relationships, and Terraform Core topologically sorts them into an execution order (formalized fully in Chapter 8).

9. Common Mistakes

  • Wrong: Hand-formatting HCL with manually-aligned = signs. Why: it drifts out of alignment the moment anyone edits a line, and creates noisy diffs. Correct: run terraform fmt before every commit; enforce it in CI with terraform fmt -check.
  • Wrong: Using a resource block to represent something you don’t actually want Terraform to manage (e.g., a shared VPC owned by a networking team). Why: Terraform will try to “own” its lifecycle, risking accidental modification or deletion of infrastructure another team depends on. Correct: use a data block to reference it read-only.
  • Wrong: Outputting entire resource objects instead of specific attributes. Why: it turns every attribute into an implicit, unintentional public contract that can break on a provider upgrade. Correct: output only the specific attributes consumers actually need, each with a description.

10. Production Tips

  • 🚀 Best Practice: Every variable and output block should have a description — six months from now, that description is the only documentation some engineers will read before using your module.
  • 💡 Tip: Group related arguments visually with blank lines within a resource block (e.g., core config, then tags) — fmt won’t do this for you, but it’s a cheap readability win.
  • ⚠️ Warning: Don’t name local resource names (aws_instance.web) after environment-specific values (aws_instance.web_prod) — the local name should describe what it is, not where it’s deployed; environment differences belong in variables, not in the resource’s address.

11. Debugging

Symptom Likely Cause Fix
Error: Unsupported argument Typo’d an argument name, or used one that doesn’t exist for this resource type/provider version Check the provider’s resource documentation for the exact argument name and your pinned provider version
Error: Reference to undeclared resource Referenced aws_instance.web before it exists, or misspelled the local name Check spelling; confirm the resource block is actually present in a file Terraform is loading
terraform fmt changes huge parts of an old file The file was never run through fmt and has accumulated inconsistent style Run fmt once, review the diff carefully, commit it as its own “formatting only” commit separate from logic changes
Output shows (sensitive value) when you expected to see it sensitive = true is set on that output or a value it derives from Use terraform output -json locally if you specifically need to see the raw value for debugging (never in shared CI logs)

12. Interview Questions

Q — When would you choose a data source over a resource for something that technically already exists? Whenever the thing exists outside this configuration’s ownership — created by another team, another Terraform root module, or manually — and you only need to read its attributes, not manage its lifecycle. Using a resource block instead risks Terraform trying to “adopt” and potentially modify or destroy infrastructure it doesn’t actually own.

Q — What’s wrong with an output block that returns an entire resource object rather than named attributes? It turns every attribute of that resource into an implicit part of the module’s public contract, even ones you never intended to expose. A future provider version can rename or restructure attributes you never meant consumers to depend on, breaking them in ways your module’s actual behavior didn’t change.

Q — Why doesn’t argument order inside a block matter in HCL, and what does that tell you about how Terraform actually processes configuration? HCL is declarative — you’re describing a set of key/value relationships, not a sequence of steps. Terraform builds its execution order from the dependency graph (based on references between blocks), not from the textual order you happened to write things in. This is a strong signal, even before Chapter 8 formalizes it, that Terraform “reads” configuration very differently from how a script executes top-to-bottom.

13. Hands-on Lab

Goal: practice the resource → output chain without a cloud account, using random and local providers.

  1. Write a configuration with a random_pet resource (generates a random name) and a local_file resource whose content includes that name.
  2. Add an output that exposes just the random pet’s id attribute, with a description.
  3. Run plan, then apply, then terraform output to see the output value printed.

Expected output: a file is created containing a random pet name, and terraform output prints the pet name under your output’s declared name.

Solution

This is a small but complete rehearsal of the resource → output chain from this chapter's main example, using zero-cost local providers so you can run it anywhere.

14. Mini Project

Refactor the Chapter 1 mini project’s outputs. Take the S3 bucket configuration from Chapter 1:

  1. Add a data source for the current AWS account ID (aws_caller_identity).
  2. Add an output for the bucket’s ARN, using string interpolation to also include the account ID (e.g., a comment noting the account this bucket belongs to) — purely to practice expressions inside output values.
  3. Give every output a description.
  4. Run terraform validate and terraform fmt -check before applying — treat both as required, not optional, steps.

16. Summary

  • HCL has three core building blocks: blocks (with a type and labels), arguments (name = value), and expressions.
  • resource blocks manage lifecycle; data blocks only read — using the wrong one against infrastructure you don’t own is a real production risk.
  • Argument order inside a block never matters — Terraform’s execution order comes from the dependency graph, not textual position.
  • Output blocks are a public contract — expose specific, named, described attributes, not whole resource objects, and mark truly sensitive values sensitive = true.

17. Cheat Sheet

resource "<type>" "<name>" { ... }
data "<type>" "<name>" { ... }
output "<name>" {
  value       = <expression>
  description = "..."
  sensitive   = false
}
terraform fmt          # auto-format
terraform fmt -check   # CI-friendly, fails if formatting is needed
terraform validate     # syntax + internal consistency

Next: Chapter 4 goes deep on variable blocks — types, validation, defaults — the piece of HCL that makes configurations reusable across environments instead of hardcoded. Chapter 5 covers the expression language itself (functions, conditionals, for_each) in much more depth than this chapter’s brief mention.

🔒 Locked
Sign in and unlock the full syllabus to keep reading.
Sign in to continue
← Terraform Workflow & CLIVariables, Locals, and Outputs →