Skip to content

Terraform HCL

HCL (HashiCorp Configuration Language) is Terraform’s declarative configuration language. Rather than describing how to build infrastructure step by step, you describe what the infrastructure should look like - Terraform figures out the correct order and sequence automatically.


In HCL, blocks are the primary language construct - the “nouns” of the language. Everything in Terraform is expressed as a block. All blocks share a uniform structure:

<block_type> "<label_1>" "<label_2>" {
argument_name = value
subblock {
nested_argument = value
}
}

The very first word of a block is its block type, which determines how its contents are interpreted. Terraform has 12 built-in block types:

Block TypePurpose
terraformConfigures Terraform and the workspace
providerSpecifies settings for vendor connections
resourceCreates and manages infrastructure components
dataPerforms read-only lookups of existing infrastructure
variableDeclares external input parameters
localsDeclares internally scoped computed values
moduleCalls and configures a reusable module
outputExports values from a module
importBrings existing infrastructure under Terraform management
movedTells Terraform a resource has been renamed or relocated
removedRemoves a resource from state without destroying it
checkValidates the state of deployed infrastructure

Following the block type, most blocks include one or more quoted labels that give the block a unique identity for cross-referencing:

StrategyWhich blocksExample
No labelsterraform, localsOnly one instance per module; no identity needed
Single labelvariable, provider, module, outputvariable "instance_type"
Subtype + identifierresource, dataresource "aws_instance" "web_server"

For resource and data blocks, the two labels together form a unique reference string:

resource.aws_instance.web_server
data.aws_ami.ubuntu

Terraform uses these reference strings to track dependencies across the entire configuration.

Inside a block body, two things can appear:

  • Arguments - assign a value to a named field using =. Each argument name can only appear once per block. Values can be strings, booleans, numbers, objects, lists, or null.
  • Subblocks - nested blocks without =, without labels, and can be repeated. A common use case is stacking multiple filter subblocks inside a data block.
data "aws_subnets" "cluster" {
filter {
name = "tag:network_tier"
values = ["compute_cluster"]
}
filter {
name = "availabilityZone"
values = ["us-east-1a", "us-east-1b"]
}
}

Blocks also export attributes - read-only values that other blocks can reference. Attributes include:

  • All arguments the block was given (re-exported as attributes)
  • Computed values produced by the provider after creation (e.g., an auto-assigned ARN, an IP address, an instance ID)
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id # referencing an attribute of the data block
instance_type = "t3.micro"
}

In Terraform, the order blocks are written in is irrelevant. You can define blocks in any sequence, spread them across multiple files in the same directory - Terraform treats them all as a single flat configuration.

Instead of executing top-to-bottom, Terraform builds a directed acyclic graph (DAG) by tracing which block’s attributes are consumed as arguments by other blocks. This graph determines the correct creation order automatically.

# Terraform knows to fetch the VPC first, then the subnet,
# purely because the subnet filter references vpc_data.id
data "aws_vpc" "default" {
default = true
}
data "aws_subnets" "cluster" {
filter {
name = "vpcId"
values = [data.aws_vpc.default.id] # implicit dependency
}
}

This automatic dependency resolution is one of Terraform’s most powerful capabilities.

Terraform doesn’t enforce formatting to run, but HashiCorp publishes style guidelines that make codebases significantly easier for teams to read. The recommended order inside a block is:

  1. Meta-arguments at the top (provider, depends_on, count, for_each)
  2. Block-specific arguments, grouped logically, equals signs vertically aligned
  3. Block-specific subblocks
  4. lifecycle subblock at the very bottom

Use terraform fmt to automatically realign equals signs and apply whitespace conventions:

Terminal window
terraform fmt # format current directory
terraform fmt -recursive # format all subdirectories

Providers are the plugins that allow Terraform to communicate with external systems - cloud platforms, DNS registrars, monitoring services, and more. They act like vendor SDKs, telling Terraform how to interpret and manage specific infrastructure components.

Always declare provider dependencies explicitly inside the terraform settings block. This pins the acceptable version range and protects the workspace from breaking changes in future provider releases.

terraform {
required_version = ">= 1.6"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = ">= 4.0"
}
}
}

required_providers installs the dependency; the provider block configures it. Provider blocks live in the root module and handle two concerns:

  • Authentication - API tokens, service account keys, credential files
  • Scoping - which region, project, or account to operate in
provider "google" {
project = "my-gcp-project"
region = "us-central1"
}

Many providers can read authentication from environment variables or local credential files (e.g., GOOGLE_APPLICATION_CREDENTIALS, AWS_ACCESS_KEY_ID), making the provider block optional for auth in those cases.

By default one configuration exists per provider. When you need to manage resources in multiple regions or accounts within the same provider, use aliases:

provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "west"
region = "us-west-2"
}
resource "aws_instance" "backup" {
provider = aws.west # explicit alias selection
ami = "ami-0abc123"
instance_type = "t3.micro"
}

Running terraform init downloads the declared providers from the Terraform Registry and generates a dependency lock file:

Terminal window
terraform init

The lock file (.terraform.lock.hcl) records the exact installed versions, ensuring future init calls reproduce the same selections:

# .terraform.lock.hcl (auto-generated, commit to source control)
provider "registry.terraform.io/hashicorp/google" {
version = "5.12.0"
constraints = "~> 5.0"
hashes = [...]
}

The resource block is the heart of Terraform - it declares a piece of infrastructure that Terraform should create, update, and manage.

resource "google_compute_instance" "web" {
name = "web-server"
machine_type = "e2-medium"
zone = "us-central1-a"
boot_disk {
initialize_params {
image = data.google_compute_image.ubuntu.self_link
}
}
network_interface {
network = "default"
}
}

Two labels uniquely identify a resource: the subtype (maps to the provider, e.g., google_compute_instance) and the identifier (user-defined, e.g., web). Two resources with the same subtype cannot share the same identifier.

Data sources perform read-only lookups - they never create or destroy infrastructure. Use them to query existing resources and feed their attributes into your configuration dynamically.

data "google_compute_image" "ubuntu" {
family = "ubuntu-2404-lts"
project = "ubuntu-os-cloud"
}
resource "google_compute_instance" "web" {
boot_disk {
initialize_params {
image = data.google_compute_image.ubuntu.self_link # dynamic lookup
}
}
}

Why data sources over hardcoded values? An AMI or image ID changes every time a new version is released. Hardcoding forces manual updates; a data source always fetches the latest.

Data sources accept filter subblocks, which can be stacked to narrow searches:

data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical's AWS account
filter {
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-*-22.04-amd64-server-*"]
}
}

Attributes from one data source feed directly into another, creating an implicit dependency chain:

data "aws_vpc" "main" {
default = true
}
data "aws_subnets" "public" {
filter {
name = "vpcId"
values = [data.aws_vpc.main.id] # chain: VPC ID → subnet lookup
}
}

Terraform resolves the VPC data source first automatically.

Data source typeNo match result
Singular lookup (aws_ami, aws_vpc)Error - halts the plan
List/set lookup (aws_subnets, aws_instances)Returns empty list - no error

Meta-arguments are built directly into HCL - they are universal, not vendor-specific, and change how Terraform processes a block rather than what infrastructure it creates.

Selects a named provider alias for a specific resource or data source, rather than using the default provider configuration:

resource "aws_instance" "replica" {
provider = aws.west
ami = "ami-0abc123"
instance_type = "t3.micro"
}

Terraform maps dependencies automatically through attribute references (implicit dependencies). Use depends_on when a resource relies on another but doesn’t reference any of its attributes.

A classic example: an AWS NAT Gateway needs an active Internet Gateway before it can launch, but references no IGW attributes directly.

resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public.id
depends_on = [aws_internet_gateway.main] # explicit dependency
}

When depends_on is applied to a module block, the dependency cascades to every resource inside that module.

The lifecycle subblock controls replacement and update behaviour. It can only appear once per block and must always be placed at the end of the block.

resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
lifecycle {
create_before_destroy = true
ignore_changes = [tags, ami]
}
}
RuleEffect
create_before_destroy = trueCreates the replacement before destroying the old resource. Critical for HA environments where destroying first would cause downtime.
prevent_destroy = trueAny plan attempting to destroy this resource fails immediately. ⚠️ Use sparingly - it can be bypassed by deleting the resource block entirely.
ignore_changes = [attr, ...]Ignores external attribute changes. Use all to make the resource fully read-only after creation. Common for tags managed by orchestration systems or AMI IDs that update frequently.
replace_triggered_by = [ref]Forces a complete replacement (not just an update) when the referenced attribute or resource changes.

The terraform settings block also configures where Terraform stores workspace state.

terraform {
required_version = ">= 1.6"
required_providers { ... }
backend "s3" {
bucket = "my-tf-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
}
}

The classic remote state configuration. Supported backends include AWS S3, Google Cloud Storage, AzureRM, Consul, and http (for custom REST implementations).

Every backend has its own argument requirements - check the provider documentation.

A newer alternative to backend, designed for Terraform Cloud and Terraform Enterprise:

terraform {
cloud {
organization = "my-org"
workspaces {
name = "production"
}
}
}

Use either backend or cloud - not both.


Modules are collections of data sources, resources, configuration files, and templates bundled into reusable components. They act like higher-level abstractions built on top of provider resources - and unlike providers, modules are written entirely in HCL.

module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "production-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
}

Unlike resource arguments (dictated by the provider), module arguments are defined by the module author and declared inside the module using variable blocks. Modules export data back to the caller via output blocks.

TypeDescription
Root moduleThe entry point of every Terraform project - configures required_providers and calls other modules
Shared moduleA reusable module sourced from a registry, Git repo, or internal registry
SubmoduleDistributed inside a parent module (typically in a modules/ directory) to break complex functionality into manageable pieces

The standard convention for module layout:

my-module/
├── main.tf # Core resources
├── variables.tf # Input variable declarations
├── outputs.tf # Output value declarations
└── README.md # Documentation (auto-parsed by registries)
ArgumentPurpose
sourceRequired. Where to find the module - registry URL, local path, or Git ref.
versionAcceptable version range when sourcing from a registry. Always pin this.
providersPass provider aliases from the outer workspace into the module.

If depends_on is applied to a module block, the dependency propagates to every resource inside that module automatically.

Turning a hard-coded root module into a reusable shared module follows five steps:

  1. Remove provider blocks - shared modules declare required_providers but never configure providers directly
  2. Parameterise hard-coded values - replace static values with variable blocks, add type constraints and validation subblocks
  3. Expose data via outputs - add outputs.tf so callers can link this module to other modules
  4. Test locally - create an examples/ folder with a temporary root module that calls source = "../" and has the required provider block
  5. Publish - name the repository terraform-<PROVIDER>-<NAME> (e.g. terraform-aws-instance); other developers reference it by Git URL or registry path

Terraform provides dedicated blocks for safely restructuring code without destroying live infrastructure.

Brings existing infrastructure (created manually via console or CLI) under Terraform management:

import {
id = "i-0abc123456" # the cloud resource ID
to = aws_instance.web
}

Advantages over terraform import CLI command:

  • Changes are visible in terraform plan before being applied
  • Easier to automate across multiple environments (staging, production)
  • From Terraform v1.6, id can use variables or data source attributes (no hardcoding required)

Tells Terraform a resource was renamed or moved into a module, preventing accidental destruction:

moved {
from = aws_instance.old_name
to = aws_instance.new_name
}

Unlike import blocks, moved blocks are safe to leave in permanently. In a new environment where no matching resource exists, Terraform simply creates a fresh one - no harm done. This makes moved blocks safe to ship inside reusable modules.

Removes a resource from Terraform’s state management without destroying the running infrastructure:

removed {
from = aws_instance.legacy
lifecycle {
destroy = false
}
}

Use this when you want to “forget” a resource - stop tracking it in state - without tearing it down.


Before deploying infrastructure, authenticate with your cloud provider. Each provider handles authentication differently - for GCP, set GOOGLE_APPLICATION_CREDENTIALS; for AWS, run aws configure or export AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY.

Terminal window
# 1 - Initialize the workspace (download providers and modules)
terraform init
# 2 - Preview what Terraform will do
terraform plan
# 3 - Apply changes
terraform apply
CommandWhat it does
terraform initDownloads providers and modules; generates .terraform.lock.hcl
terraform planBuilds the execution plan; shows + create, ~ update, - destroy actions
terraform applyExecutes the plan; prints a summary on completion
terraform fmtAuto-formats code to style guidelines

Terraform variables are constants - a variable’s value is set once at the start of a run and never changes during execution. There are three kinds, each with a different scope.

Input variables are declared with variable blocks (conventionally in variables.tf) and act as parameters for a module:

variable "instance_type" {
type = string
default = "t3.micro"
description = "EC2 instance type for the compute node"
nullable = false
validation {
condition = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type)
error_message = "Must be one of: t3.micro, t3.small, t3.medium."
}
}
ArgumentPurpose
typeEnforces data type - stops execution early with a clear error if the wrong type is passed
defaultFallback value - makes the variable optional for callers
descriptionDocumentation shown in plan output and registry UIs
nullableWhether null is an accepted value (defaults to true)
validationCustom correctness checks before the plan runs

Input values are set by callers using module block arguments, .tfvars files, environment variables (TF_VAR_<name>), or CLI flags (-var).

Output values are declared with output blocks (conventionally in outputs.tf) and act as the return values of a module:

output "instance_ip" {
value = aws_instance.web.public_ip
description = "Public IP of the web server"
depends_on = [aws_eip_association.web]
}
ArgumentPurpose
valueThe data to return - usually an attribute of a created resource
descriptionDocuments what this output represents
depends_onForces the output to wait for another resource before resolving
sensitiveMasks the value in CLI output and logs

Outputs are how independent modules are linked together - the public_ip output of a compute module can become the target input of a firewall module.

Locals are internal intermediate values scoped entirely within the module. They cannot be accessed from outside:

locals {
full_name = "${var.project}-${var.environment}"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_instance" "web" {
tags = local.common_tags
}

Unlike other block types, locals blocks take no label and can appear multiple times across multiple files in the same module.

Mark any input or output containing secrets as sensitive = true:

variable "db_password" {
type = string
sensitive = true
}

This masks the value in all CLI output and logs. Sensitivity propagates automatically - any local or output derived from a sensitive value is also masked.


Specifying types makes your code significantly more robust. If an incorrect type is passed, Terraform halts before making any changes and provides a clear error message.

TypeKeywordNotes
StringstringUnicode-encoded; supports string interpolation ("${var.name}")
NumbernumberSingle type covers integers, floats, and negatives
Booleanbooltrue or false
TypeKeywordKey characteristic
Listlist(type)Ordered; zero-indexed. All elements must be the same type.
Setset(type)Unordered; automatically deduplicates. All elements same type.
Tupletuple([type, type, ...])Fixed length; elements may be different types in defined positions.
TypeKeywordKey characteristic
Mapmap(type)Arbitrary string keys; all values the same type. Ideal for tags.
Objectobject({key=type,...})Strict schema; each key can hold a different type. Supports optional() keys with defaults. Extra keys are dropped.
TypeDescription
nullRepresents “not set”. Useful as a default for optional inputs. Cannot be used as a type constraint.
anyAccepts any type (the default if no type is declared). In collections (list(any)), all elements must still share the same type.

Mathematical - standard arithmetic supported natively; use Terraform functions for anything more complex (exponents, rounding):

count = var.instance_count * 2

Comparison - ==, !=, <, <=, >, >=

Boolean - || (OR), && (AND), ! (NOT)

Operator precedence (high → low):

!, - (negation / unary minus)
*, /, %
+, -
>, >=, <, <=
==, !=
&&
||

Use parentheses liberally - they make intent explicit and reduce editing errors.

count = var.enable_monitoring ? 1 : 0

The condition evaluates to a boolean; if true the second operand is returned, otherwise the third.

Interpolation embeds expressions directly into strings:

name = "${var.project}-${var.environment}-web"

file function - load a large static string from a file:

user_data = file("${path.module}/scripts/init.sh")

Path variables: path.module (current module), path.root (root module), path.cwd (working directory).

templatefile function - inject dynamic values into a template file:

user_data = templatefile("${path.module}/templates/nginx.conf.tpl", {
server_name = var.domain
port = var.port
})

Terraform’s template language also supports logic inside %{...} directives:

%{ if var.enable_https }listen 443 ssl;%{ endif }
%{ for host in var.allowed_hosts }allow ${host};%{ endfor }

Terraform uses Golang regex syntax (use Regex101 in Golang mode to build patterns).

FunctionBehaviour on no matchReturn type
regex(pattern, str)ErrorString, list (unnamed groups), or map (named groups)
regexall(pattern, str)Empty listList of strings / list of lists / list of maps
replace(str, pattern, replacement)Original stringString

Capture group references in replace: unnamed → $1, $2; named → $groupname.

Implicit conversion happens automatically for simple cases (string "true" ↔ bool true), but not inside equality operators.

Explicit conversion functions - use to normalise module outputs:

tostring(42) # "42"
tonumber("3.14") # 3.14
tobool("true") # true
tolist(toset(["a","b"])) # list from set
toset(["a","b","a"]) # {"a","b"} - deduplicates

Sensitive / nonsensitive - these functions return a new value with the sensitivity flag changed (they do not mutate the original):

locals {
safe_url = nonsensitive("https://${var.api_key}@api.example.com")
}

Use these only for expected failures - never to hide genuine bugs.

# try - return the first expression that doesn't error
locals {
port = try(var.config.port, 8080) # fall back to 8080 if .port doesn't exist
}
# can - convert success/failure into a boolean
variable "subnet_id" {
validation {
condition = can(regex("^subnet-", var.subnet_id))
error_message = "Must be a valid AWS subnet ID (subnet-xxxxxxxx)."
}
}
FunctionReturns
try(expr1, expr2, ...)First non-erroring expression value
can(expr)true if expression succeeds, false if it errors

Best used inside validation blocks and output blocks to normalise data.

Terraform functions are pure data transformers - they take inputs, return a value, and never create or modify infrastructure.

functions_example = merge(
var.common_tags,
{ Name = "${var.name_prefix}-${count.index}" }
)

Standard library categories:

CategoryExample functions
Numericabs, ceil, floor, max, min, pow
Stringformat, lower, upper, trim, split, join
Collectionmerge, concat, flatten, distinct, lookup, keys, values
Encodingjsonencode, jsondecode, yamlencode, base64encode
Filesystemfile, templatefile, fileset
Date & Timetimestamp, formatdate, timeadd
Hash & Cryptosha256, md5, bcrypt
IP Networkcidrsubnet, cidrhost, cidrnetmask
Type Conversiontostring, tonumber, tobool, tolist, toset, tomap

Because Terraform is declarative, standard loops don’t apply. Instead, two meta-parameters create multiple instances of a block, and two expressions transform groups of data.

count and for_each are mutually exclusive - you cannot use both on the same block.

Accepts an integer and creates that many copies. Best for simple multiplication or boolean toggles:

resource "aws_instance" "workers" {
count = var.instance_count
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = { Name = "worker-${count.index}" } # count.index starts at 0
}
# Boolean toggle pattern
resource "aws_cloudwatch_log_group" "app" {
count = var.enable_logging ? 1 : 0
name = "/app/logs"
}

Accessing count-created resources: aws_instance.workers[0].public_ip, or all IPs with aws_instance.workers[*].public_ip.

Accepts an object, map, or set and creates one instance per element, each with its own configuration:

resource "aws_s3_bucket" "env_buckets" {
for_each = toset(["dev", "staging", "prod"])
bucket = "my-app-${each.key}"
}

Inside a for_each block, each.key and each.value are available:

Input typeeach.keyeach.value
Map/objectThe map keyThe map value
SetThe element valueThe element value

Critical constraint: Values passed to count or for_each must be known at plan time. You cannot use impure functions (uuid(), timestamp()) or attributes that are only computed after resource creation. If you hit this constraint with for_each, refactor to use count + count.index instead.

Transforms a collection into a new collection (does not create resources):

# List output - square brackets
locals {
upper_names = [for name in var.names : upper(name)]
}
# Map/object output - curly brackets with => operator
locals {
name_map = { for id, name in var.servers : id => upper(name) }
}
# With filtering
locals {
active_only = [for s in var.servers : s if s.enabled]
}
# Grouping mode - accumulate multiple values under the same key
locals {
by_region = { for s in var.servers : s.region => s.name... } # note the ellipsis
}
SyntaxInputOutput
[for v in list : expr]list/setlist
{for k, v in map : key => val}map/objectobject
... if conditionanyfiltered collection
value... (grouping mode)anyobject with list values

An elegant shorthand for the most common for pattern - extracting one attribute from a count or for_each result:

i.public_ip]
output "worker_ips" {
value = aws_instance.workers[*].public_ip
}
# Also converts a single nullable value into a list
locals {
maybe_list = var.optional_value[*] # [] if null, [value] otherwise
}

Standard arguments can only appear once per block. Subblocks (like ingress rules or filter clauses) can repeat - but when the number of repetitions depends on user input, you need dynamic blocks:

resource "aws_security_group" "web" {
name = "web-sg"
# Static subblock - always present
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
# Dynamic subblock - one ingress rule per port
dynamic "ingress" {
for_each = var.allowed_ports
content {
from_port = ingress.value # iterator named after the subblock type
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}

Two structural rules unique to dynamic blocks:

  1. Custom iterator name - the iterator variable is named after the subblock type (ingress.value, not each.value)
  2. content block required - all arguments go inside a nested content { } block

Use a conditional that switches between an empty list (off) and a single-element list (on):

dynamic "ingress" {
for_each = var.enable_https ? ["placeholder"] : []
content {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}

The string "placeholder" is never used inside content - it just provides one iteration to create one block.

Static and dynamic subblocks of the same type can coexist in the same resource - a common pattern is hardcoding an egress rule while generating ingress rules dynamically.