Skip to content

Terraform Language and Features

HashiCorp Configuration Files

Terraform configuration files (with .tf or .tf.json extensions) contain provider, resource, variable, and module definitions. They are written using the Terraform Language, which is based on HCL.

resource "aws_vpc" "main" {
    cidr_block = var.base_cidr_block
}

<BLOCK_TYPE> "<BLOCK_LABEL>" "<BLOCK_LABEL>" {
    # Block Body
    <IDENTIFIER> = <EXPRESSION> # Argument
}

Terraform can use JSON as an alternative to HCL. This is useful when generating configuration dynamically, because you can rely on existing JSON tooling libraries.

{
    "resource": {
        "aws_instance": {
            "example": {
                "instance_type": "t2.micro",
                "ami": "ami-abc123"
            }
        }
    }
}

HashiCorp Configuration Language (HCL)

HCL is an open-source configuration language designed to be both human-readable and machine-friendly. Many HashiCorp tools use HCL:

  • Terraform (.tf), including extended features like dynamic blocks
  • Packer (.pkr.hcl)
  • Vault policies
  • Boundary configuration
  • Consul configuration
  • Waypoint configs
  • Nomad job specs (.nomad)
  • Shipyard blueprints

Terraform Settings

The special top-level terraform block is used to configure Terraform's own behavior — not provider or resource behavior.

terraform {
    required_providers {
        aws ={
            version = ">= 2.7.0"
            source  = "hashicorp/aws"
        }
    }
}

Additional terraform {} settings include:

  • required_version — the required Terraform CLI version
  • experiments — opt-in to experimental language features
  • provider_meta — module-specific metadata for certain providers

Version Constraints

Terraform uses Semantic Versioning (MAJOR.MINOR.PATCH).

Version constraint operators:

  • = or no operator → exact version (e.g.: "=1.0.0" or "1.0.0")
  • != → exclude version (e.g.: "!=1.0.0")
  • , >=, \<, \<= → comparisons (e.g.: ">=1.0.0")

  • ~> → “pessimistic constraint”:
  • ~> 1.0.0 → allows only patch upgrades
  • ~> 1.2 → allows minor & patch upgrades

Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.

Use the latest stable versions of Terraform, providers, and modules to maintain security, modernity, and development agility.


Named Values

Named values are built-in namespaces that expose various values:

  • Resources: <resource_type>.<name>, e.g.: aws_instance_my_server
  • Variables: var.<name>
  • Locals: local.<name>
  • Module outputs: module.<name>
  • Data sources: data.<type>.<name>

Filesystem/workspace info:

  • path.module — path to the module containing the expression
  • path.root — root module path
  • path.cwd — current working directory
  • terraform.workspace — name of the currently selected workspace

Block-local values:

  • count.index (when using the count meta argument)
  • each.key / each.value (when using the for_each meta argument)
  • self.<attribute> - self reference information within the block (provisioners and connections)

Named values look like maps but are not objects — you cannot index them with [].


Input Variables

Input variables act as parameters for Terraform modules. They are defined using the variable block:

variable "whatever" {
    type = "string"
    default = "whatever"
    description = "whatever whatever"
    validation = ... # block to define validation rules, usually in addition to type contraints
    sensitive = ... # limits Terraform UI output when the variable is used in configuration
}

Variable definition files allow you to specify values for multiple variables at once. They must end in:

  • .tfvars
  • .tfvars.json

Terraform automatically loads a file named terraform.tfvars if present in the root module.

You can set variables using environment variables prefixed with TF_VAR_: export TF_VAR_image_id="ami_abc123"

Terraform loads variable values in the following precedence (highest overrides lowest):

  1. Environment variables: TF_VAR_<name>
  2. Autoloaded file: terraform.tfvars
  3. Manually loaded files: terraform.tfvars.json
  4. Automatically loaded files: *.auto.tfvars or *.auto.tfvars.json
  5. Inline CLI variables: -var and -var-file CLI arguments

Local Values

Local values (defined in locals blocks) assign names to expressions so they can be reused without repetition. You can define multiple locals blocks in a single file (even nested - used within itself).

locals {
    service_name    = "forum"
    owner           = "whoever"
}

locals {
    common_tags = {
        service = local.service_name
        owner   = local.owner
    }
}

Notes:

  • Use local.<name> (singular) to reference them.
  • Best practice: Use locals sparingly. Excessive locals reduce clarity in declarative infrastructure.

Data Sources

Data sources allow Terraform to retrieve read-only information from external systems or other Terraform states.

data "aws_ami" "web" { # You specify what kind of external resource you want to select
    filter { # You use filters to narrow down the selection
        name    = "state"
        values  = ["available"]
    }

    filter {
        name    = "tag:component"
        values  = "web"
    }

    most_recent = true
}

resource "aws_instance" "web" {
    ami             = data.aws_ami.web.id
    instance_type   = "t3.micro"
}

Resource Meta-Arguments

Meta-arguments modify how resources behave. They apply to all resource types.

I. depends_on

Manually declare an explicit dependency:

resource "aws_iam_role_policy" "example" {
    name    = "example"
    role    = "whatever"
}
resource "aws_instance" "web" {
    # ...
    depends_on = [
        aws_iam_role_policy.example
    ]
}

II. count

Create multiple resource instances based on a count:

resource "aws_instance" "server" {
    count = 4 # create four similar ec2 instances
    # ...
    tags = {
        Name = "Server ${count.index}" # get the current count value (index) - starts at 0
    }
}
  • count can accept numeric expressions (e.g.: count = length(var.subnet_ids))

III. for_each

Used to create multiple resource instances according to a map, or set of strings. You can iterate over a map for more dynamic values.

resource "azurerm_resource_group" "rg" {
    for_each = {
        first_group = "eastus"
        second_group = "westus2"
    }
    name = each.key
    location = each.value
    # or
    for_each = toset( ["first_group", "second_group"] )
    name = each.key # but there won't be values since they are not provided - only keys
}

IV. lifecycle

Control how Terraform handles create/update/destroy:

resource "azurerm_resource_group" "rg" {
    # ...
    lifecycle {
        create_before_destroy = true # (bool) when replacing a resource, first create the new resource before deleting it (by default, it destroys the old one first)
        # or
        prevent_destroy = true # (bool) ensures a resource is not destroyed
        # or
        ignore_changes = [] # (list of attributes) don't change the resource if a change occurs for the listed attributes
    }
}

V. provider and alias

Used for selecting a non-default provider configuration

provider "aws" {
    alias   = "west"
    region  = "us-west-2"
}
resource "aws_instance" "foo" {
    provider = aws.west
}

VI. provisioner and connection

Used for taking extra actions after resource creation (not recommended for most cases; use cloud-init or configuration management tools instead).


Expressions

Terraform expressions are used to compute values or reference other values within a configuration. Every expression evaluates to a value, and every value has a type.

I. Primitive Types

A. string

  • Uses double quotes: "hello"
  • Supports escape sequences: "\n", "\t", "\\", "$${", "%%{"
  • Supports multi-line heredoc strings:
<<EOT
multi
line
text
EOT
  • String interpolation: "hello, ${var.name}"
  • String directives (inline conditional logic): "hello, %{ if var.name != "" }${var.name}%{ else }%some_value%{ endif }"
  • Directives can also be used in heredocs.
  • Strip whitespace using ~: "${var.name ~}"

B. number: numeric types (integers or floats).

C. bool: boolean values: true, false.


II. The null Type

Represents “no value” or “use the default”. Used when you want to use the underlying default of a provider's resource configuration option. It does not necessarily mean “empty.”

III. Complex Types

Complex types group multiple values into a single structure. There are two categories of complex types:

A. Collection Types

Store multiple values of the same type.

  • list(type): Ordered, indexable sequence.
  • map(type): Key–value pairs.
  • set(type): Unordered, unique values; coerced based on the first element’s type.

B. Structural Types

Group multiple values of different types. Require a schema.

  • object({ … })
variable "human" {
    type = object ({
        name = string
        surname = optional(string)
        age = number
    })
    default = {
        name = "John"
        age  = 52
    }
}
  • tuple([ … ])
variable "human" {
    type = tuple([string, number, bool])
    default = ["John", 52, true]
}

Operators

  • multiplication: a*b
  • division: a/b
  • modulus: a%b
  • addition: a+b
  • subtraction: a-b
  • flip to negative (*-1): -a
  • equals: a==b
  • does not equal: a != b
  • less than: a\<b
  • less than or equal: a\<=b
  • and: a && b
  • or: a || b
  • flip boolean: !a

Conditional Expressions

Terraform supports ternary if else conditions: condition ? true_val : false_value

The return types of both branches must match.


For expressions

Allows you to iterate over a complex type and apply transformations (can iterate over a list, set, tuple, map or an object).

[for s in var.list: upper(s)] # Uppercase each string in the list
# or
[for k, v in var.map: length(k) + length(v)] # sum of key+value
# or
[for i, v in var.list: "${i}: ${v}"] # you can also get the index from a list
# or
{for s in var.list: s => upper(s)} # {hello = "HELLO", world = "WORLD" }
# or
[for s in var.list: upper(s) if s != ""]

Splat expressions

Provide a shorter expression for for expressions. A splat operator is represented by an asterisk (*) and it originates from the ruby language. Splats in Terraform are used to rollup or soak up a bunch of iterations in a for expression.

[for o in var.list: o.id] <=> var.list[*].id
# and
[for o in var.list: o.interfaces[0].name] <=> var.list[*].interfaces[0].name

Special behavior:

  • If the value is non-null, the result becomes a single-element list.
  • If the value is null, the result is an empty tuple.

Dynamic Blocks

Used to programmatically create repeated nested blocks:

locals {
    ingress_rules = [
        { port = 443 },
        { port = 80 },
    ]
}
resource "aws_security_group" "main" {
    #...
    dynamic "ingress" {
        for_each = local.ingress_rules
        content = {
            # ...
            from_port   = ingress.value.port
            to_port     = ingress.value.port
        }
    }
}