HCL#

HCL, the HashiCorp Configuration Language, is the configuration / declarative DSL behind Terraform, OpenTofu, Packer, Vault, Consul, Nomad, and many adjacent tools. Designed to be more readable than JSON and more structured than YAML, with first-class expressions and references.

In 2026 HCL2 is the version everyone uses; the original (HCL1) is historical.

What HCL Looks Like#

# Provider configuration
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

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

# Locals (computed values)
locals {
  bucket_name = "myorg-${var.env}-logs"
  tags = {
    Environment = var.env
    Owner       = "data-team"
  }
}

# Resources
resource "aws_s3_bucket" "logs" {
  bucket = local.bucket_name
  tags   = local.tags
}

# Outputs
output "bucket" {
  value = aws_s3_bucket.logs.id
}

Building Blocks#

The structural primitives of HCL. Blocks introduce sections; arguments fill them; expressions reference other blocks. The exact valid block names depend on the consuming tool – Terraform’s vocabulary differs from Vault’s, but the structure of every block is the same.

  • Block, type "label" { ... }. The major structuring unit.

  • Argument, key = value inside a block.

  • Identifier, the name on the left.

  • Expression, the value on the right; can reference other blocks.

  • Object / map / list / tuple, nested values.

The exact valid block types depend on the consuming tool. Terraform defines terraform, provider, resource, data, variable, output, module, locals. Packer / Vault / Nomad each define their own.

Expressions#

HCL is a real expression language.

# Strings with interpolation
bucket = "myorg-${var.env}-logs"

# Heredocs
policy = <<-EOT
  {
    "Version": "2012-10-17",
    "Statement": [...]
  }
EOT

# Conditional
storage_class = var.env == "prod" ? "STANDARD" : "STANDARD_IA"

# For expressions (list comprehension)
names = [for u in var.users : u.name]

# For expressions (object)
user_emails = { for u in var.users : u.id => u.email }

# Splat expressions
ids = aws_instance.app[*].id

Built-In Functions#

The standard library exposed by the consuming tool. Common needs (list and map manipulation, JSON and YAML encode/decode, file reading, template rendering, CIDR arithmetic, base64) are all built in. The full set is documented per tool; the table below covers the everyday hits.

Function

Use

length(list)

list / map / string length

contains(list, x)

membership

concat(a, b)

join lists

merge(a, b)

merge maps (right wins)

join(sep, list)

list → string

split(sep, str)

string → list

format("%-10s", x)

sprintf-style formatting

jsonencode(obj) /

jsondecode(str)

JSON conversion

yamlencode / yamldecode

file("path")

read a file

templatefile("p", vars)

render a template

cidrsubnet / cidrhost IP-address arithmetic

timestamp()

current time

base64encode / base64decode

sensitive(value)

mark a value as sensitive (don’t print)

The full set is documented in each tool’s reference.

References and Dependencies#

Inside a Terraform configuration, HCL expressions can reference other blocks. Terraform builds a dependency graph from these references.

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "a" {
  vpc_id     = aws_vpc.main.id           # dependency edge
  cidr_block = "10.0.1.0/24"
}

You don’t CREATE TABLE order things by hand; the tool computes the order from the references.

Modules#

A Terraform “module” is just a directory of .tf files. Other modules consume it by module blocks:

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"
  version = "5.0.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"
}

module "app" {
  source = "./modules/app"

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets
}

Modules are the unit of reuse. Inputs are variable; outputs are output.

Tools That Use HCL#

The HCL footprint runs across nearly every HashiCorp product. Terraform and OpenTofu lead; Packer builds images; Vault holds policies; Consul, Nomad, Boundary, and Waypoint each bring their own block schema. The DSL is constant; the valid block types differ.

  • Terraform / OpenTofu, infrastructure as code.

  • Packer, machine image builds (source { ... } build { ... }).

  • Vault, HCL policies (path "secret/*" { capabilities = [...] }).

  • Consul, service definitions, configuration.

  • Nomad, job specs.

  • Boundary, session management.

  • Waypoint, application deployment.

The DSL is the same; the block schema differs.

JSON-Compatible Form#

Every HCL document has a JSON equivalent (.tf.json). Useful for generating configuration programmatically.

{
  "resource": {
    "aws_s3_bucket": {
      "logs": { "bucket": "myorg-prod-logs" }
    }
  }
}

Modules vs. Programs#

HCL is declarative. There are no loops in the imperative sense, no if statements that block execution. Repetition is via:

  • count, count = length(var.names); each instance gets count.index.

  • for_each, for_each = toset(var.names); each gets a key / value.

  • For-expressions, [for x in xs : f(x)].

  • Modules, the unit of programmatic composition.

If your needs exceed these (real if-then-else, real loops, dynamic plan-time logic), HashiCorp’s CDKTF or Pulumi (write infra in TypeScript / Python / Go) is a better fit. CUE is an alternative declarative language with a stronger type system.

Linting and Validation#

The CI guardrails that turn HCL from “looks right” into “verified before merge”. terraform fmt enforces standard style; terraform validate checks the schema; tflint catches anti-patterns; tfsec, Checkov, and KICS scan for security misconfigurations.

  • terraform fmt, standard formatter; run on save.

  • terraform validate, schema validation.

  • tflint, linter.

  • tfsec / Checkov / KICS, security scanning.

  • Atlas, schema migrations adjacent.

Pitfalls#

The traps that catch teams new to HCL. The non-Turing-complete boundary frustrates imperative-trained authors; heredoc indentation has two flavors; type coercion between strings, numbers, and booleans is not silent; sensitive values leak into plan output if not marked.

  • HCL is not Turing-complete, when a need feels imperative, it often is. Reach for CDKTF / Pulumi rather than fighting HCL.

  • Heredoc indentation, <<-EOT strips the smallest common indentation; <<EOT keeps it.

  • String / number / bool coercion, be explicit; var.x == "true" is not the same as var.x == true.

  • Sensitive values, mark them; they appear in plan output otherwise.

  • JSON output of HCL, some types (functions) don’t round-trip; generated configs need care.

  • Provider version drift, pin required_providers versions; use the lockfile (.terraform.lock.hcl).

When (Not) to Use HCL#

  • Use for any HashiCorp tool; HCL is the native config there.

  • Don’t use outside its tools; it’s not a general-purpose config language. (You can, via the parser libraries, but YAML / TOML / JSON serve general configuration better.)

  • Don’t use when you need real programming, pick CDKTF (HCL generated from TS / Python / Go), Pulumi, or generate .tf.json from a real language.

See Also#