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"
  }
}
  • resource is 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.