The HCL Language: The Building Blocks

Every .tf file is written in HCL. HCL is made of blocks. A block looks like:

HCL
block_type "label_one" "label_two" {
  argument = value
  nested_block {
    other = value
  }
}

There are only a handful of block types you need to know. Here are all of the ones used in this repository.

5.1 resource - a thing to create

A resource block declares one piece of infrastructure. This is the heart of HCL.

HCL
resource "aws_kms_key" "state" {
  description         = "OpenTofu state encryption"
  enable_key_rotation = true
}
  • "aws_kms_key" is the resource type (defined by the AWS provider).
  • "state" is a local name you pick, used to refer to it elsewhere in the code. It is not visible in AWS.
  • Inside the braces are the resource's arguments.

To reference an attribute of this resource elsewhere, you write aws_kms_key.state.arn (type.name.attribute). Real example from bootstrap/main.tf, where the bucket encryption rule points at the key created above:

HCL
kms_master_key_id = aws_kms_key.state.arn

This reference is also how OpenTofu learns the order to create things: because the bucket refers to the key, OpenTofu knows the key must exist first. You almost never specify ordering manually - it is inferred from references.

5.2 variable - an input

A variable is an input you can set from outside, so the same code can behave differently per environment. From envs/dev/variables.tf:

HCL
variable "az_count" {
  description = "Number of AZs. Dev runs lean at 2."
  type        = number
  default     = 2
}
  • type constrains what's allowed (string, number, bool, list(...), map(...), etc.).
  • default makes it optional. With no default, the value must be supplied.
  • You read a variable's value with var.az_count.

Variables can also validate their input. From bootstrap/variables.tf:

HCL
variable "environment" {
  type = string
  validation {
    condition     = contains(["management", "dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: management, dev, staging, prod."
  }
}

If you pass anything else, OpenTofu stops with that error message before touching AWS.

5.3 output - a result to expose

An output publishes a value after apply - for humans to read, or for other layers to consume. From bootstrap/outputs.tf:

HCL
output "state_kms_key_arn" {
  description = "KMS key ARN encrypting state."
  value       = aws_kms_key.state.arn
}

After tofu apply, you can run tofu output state_kms_key_arn to print it. The Makefile uses exactly this to copy the KMS key ARN into the env backends.

5.4 local - a named intermediate value

locals are like local constants/variables inside the code - computed once, reused for readability. From envs/dev/main.tf:

HCL
locals {
  name_prefix = "${var.project}-${var.environment}"   # e.g. "alphaform-dev"

  common_tags = merge(
    {
      Project     = var.project
      Environment = var.environment
      ManagedBy   = "opentofu"
    },
    var.tags,
  )
}

You read them with local.name_prefix. The ${...} syntax is string interpolation - inserting a value into a string. merge(...) is a built-in function that combines maps (see section 14).

5.5 data - reading something that already exists

A data source looks up information without creating anything. From modules/networking/main.tf:

HCL
data "aws_availability_zones" "available" {
  state = "available"
}

This asks AWS "which AZs are available in this region?" so the code can adapt to whatever region it runs in, instead of hard-coding zone names. You read it with data.aws_availability_zones.available.names.

5.6 module - a reusable package of resources

A module is a folder of .tf files you can call from elsewhere, passing inputs and receiving outputs - like calling a function. From envs/dev/main.tf:

HCL
module "networking" {
  source = "../../modules/networking"   # where the module's code lives

  name_prefix        = local.name_prefix   # inputs (the module's variables)
  vpc_cidr           = var.vpc_cidr
  az_count           = var.az_count
  single_nat_gateway = var.single_nat_gateway

  tags = local.common_tags
}

You then use the module's outputs as module.networking.vpc_id. Modules are how this repo avoids copy-pasting the same network definition into dev, staging, and prod - the definition lives once in modules/networking/, and each environment calls it with different inputs. This is the single most important structural idea in the repo; section 11 covers it in depth.

5.7 provider - configuring the connection to AWS

A provider block configures how to talk to AWS - chiefly which region and which account. From envs/dev/providers.tf:

HCL
provider "aws" {
  region              = var.region
  allowed_account_ids = var.allowed_account_ids   # safety guardrail

  default_tags {
    tags = local.common_tags    # auto-tag every resource
  }
}

Two things worth noting here, both real safety/quality features used throughout this repo:

  • allowed_account_ids makes OpenTofu fail fast if your credentials point at the wrong AWS account - a guardrail against accidentally changing prod while aiming at dev.
  • default_tags automatically stamps every resource with the same tags (Project, Environment, ManagedBy), so resources are easy to identify and bill.

A root can declare multiple providers using an alias, to work in more than one region at once. Same file, second provider:

HCL
provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
  # ...
}

This exists because some AWS features (CloudFront certificates, CloudFront-scoped WAF) must be created in the us-east-1 region regardless of where the rest of your stack lives. The edge module is handed both providers so it can place those resources correctly (see section 12).

5.8 terraform - settings for OpenTofu itself

The terraform { ... } block configures OpenTofu, not AWS. Two uses in this repo:

  1. Declaring required versions and providers (versions.tf, shown in section 4).
  2. Declaring the backend - where state is stored (backend.tf, covered next).

That is the entire set of block types this repository uses: resource, variable, output, locals, data, module, provider, terraform. Once these click, every file becomes readable.

Adesh Tamrakar
SOFTWARE ENGINEER · VAULT

Notes, insights and random discoveries from a working engineer's vault - written for future me, published for you.