HCL Basics: Blocks, Arguments, Expressions
This chapter teaches you to read and write Terraform's configuration language so you can recognize any resource definition in the wild.
HCL in One Page
HCL (HashiCorp Configuration Language) is a declarative config language designed to be readable by humans and parseable by machines. It looks like JSON with a better haircut.
A file is a sequence of blocks. Each block has a type, optionally some labels, and a body of arguments and nested blocks.
block_type "label1" "label2" {
argument = value
nested_block {
argument = value
}
}
A real example:
resource "aws_s3_bucket" "logs" {
bucket = "my-logs"
tags = {
Environment = "prod"
}
}
resourceis the block type."aws_s3_bucket"is the first label (resource type)."logs"is the second label (a local name you pick).bucket = "my-logs"is an argument.tags = { ... }is an argument whose value is a map literal.
The Block Types You'll Use Daily
terraform Top-level settings: required version, required providers, backend.
provider Configure a provider (AWS region, credentials, etc.).
resource Create and manage a resource.
data Read an existing resource (read-only).
variable Declare an input variable.
output Expose a value after apply.
locals Define named local values.
module Instantiate a module.
You'll see all of these in this tutorial. Chapters 3, 4, and 6 cover them in depth.
Arguments
Arguments set values. Syntax: name = expression.
bucket = "my-logs"
versioning = true
count = 3
tags = { Environment = "prod" }
subnets = ["subnet-abc", "subnet-def"]
The right-hand side is an expression. Expressions include literals, references, function calls, and more.
Expressions: The Interesting Part
Literals
"hello" # string
42 # number
3.14 # number
true # bool
null # null
["a", "b", "c"] # list
{ a = 1, b = 2 } # map / object
References
Reference other things in your config using type.name.attribute syntax.
resource "aws_s3_bucket" "logs" {
bucket = "my-logs"
}
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id # reference
versioning_configuration {
status = "Enabled"
}
}
aws_s3_bucket.logs.id is "the id attribute of the aws_s3_bucket resource named logs".
References work for:
resource: aws_s3_bucket.logs.arn
data source: data.aws_ami.ubuntu.id
variable: var.region
local: local.common_tags
module output: module.vpc.vpc_id
String Interpolation
Embed expressions in strings with ${...}:
bucket = "logs-${var.environment}-${data.aws_caller_identity.current.account_id}"
Older Terraform required interpolation for every reference; modern Terraform doesn't. Prefer bare references where possible:
# Good
region = var.region
# Unnecessary
region = "${var.region}"
Use ${...} only when you're combining with a literal string.
Heredocs
Multi-line strings use heredoc syntax:
policy = <<EOT
{
"Version": "2012-10-17",
"Statement": [...]
}
EOT
For JSON, prefer jsonencode:
policy = jsonencode({
Version = "2012-10-17"
Statement = [...]
})
Less fiddly, and Terraform catches type errors.
Conditionals
instance_type = var.environment == "prod" ? "m5.large" : "t3.medium"
Standard ternary. Terraform has no if statements; conditionals go here.
For Expressions
subnet_ids = [for s in var.subnets : s.id]
tags = { for k, v in var.tags : k => upper(v) }
Same pattern as Python comprehensions, different syntax.
Functions
Terraform ships with a large library of functions. You can't define your own.
length(var.list)
lower("HELLO")
upper("hello")
concat(list1, list2)
merge(map1, map2)
jsonencode(obj)
yamldecode(str)
timestamp()
file("cloudinit.yml")
formatdate("YYYY-MM-DD", timestamp())
See the Terraform functions reference for the full list.
Nested Blocks
Some resources have nested blocks for structured arguments:
resource "aws_security_group" "web" {
name = "web"
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
Two ingress blocks. Each is a separate rule.
Nested blocks look like a list of rules. Terraform handles them as unordered sets most of the time. If you need to compute them dynamically, use dynamic blocks (Chapter 4).
Comments
Three flavors, all work:
# single-line
// single-line (alternative)
/* block
comment */
# is idiomatic.
File Layout
Terraform doesn't care how you split your config across files. All .tf files in a directory are concatenated.
The community convention:
main.tf # resources
variables.tf # input variable declarations
outputs.tf # output declarations
versions.tf # required_version and required_providers
locals.tf # locals blocks (optional)
providers.tf # provider blocks (optional)
For a small config, everything in main.tf is fine. Split when files get past ~200 lines.
Meta-Arguments
Every resource and module block supports a few meta-arguments:
resource "aws_instance" "web" {
count = 3 # how many copies
# or
for_each = toset(["a", "b", "c"]) # per-key uniqueness
depends_on = [aws_vpc.main] # explicit dependency
lifecycle {
create_before_destroy = true
prevent_destroy = true
ignore_changes = [tags]
}
provider = aws.tokyo # use an alternate provider config
# normal arguments below
ami = "ami-xxx"
instance_type = "t3.micro"
}
Chapter 4 covers these properly.
An Extended Example
An S3 bucket with versioning, encryption, and a lifecycle rule:
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = "us-east-1"
}
variable "environment" {
type = string
default = "dev"
}
locals {
bucket_name = "notes-${var.environment}-${random_id.suffix.hex}"
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "random_id" "suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "notes" {
bucket = local.bucket_name
tags = local.common_tags
}
resource "aws_s3_bucket_versioning" "notes" {
bucket = aws_s3_bucket.notes.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "notes" {
bucket = aws_s3_bucket.notes.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
output "bucket_name" {
value = aws_s3_bucket.notes.bucket
}
Read it top to bottom. Everything syntactic is now familiar. The next three chapters fill in semantics.
Formatting
Run terraform fmt to auto-format. It normalizes indentation, alignment, and spacing. Run it before commit.
Common Pitfalls
Quoting references. region = "${var.region}" when region = var.region works. Redundant and older style.
Confusing labels. In resource "aws_s3_bucket" "logs", the first label is the resource type (fixed by the provider); the second is your local name. Don't mix them up in references.
Using the wrong operator in interpolations. "${1 + 2}" is "3" (string). 1 + 2 is 3 (number). Be aware of the type difference.
Overusing heredocs for JSON. Use jsonencode. It handles escaping and catches errors.
Ignoring terraform fmt. Unformatted code is a smell and easy to fix in one command. Commit formatted code.
Next Steps
Continue to 03-variables-outputs.md to make configurations reusable.