Terraform Configuration Language (HCL) Basics
Learn HCL: blocks, types, references, string templates, conditionals, for expressions, dynamic blocks and functions, tested in terraform console.
TLDR: Terraform code is written in HCL, the HashiCorp Configuration Language. A file is a list of blocks (resource, variable, output, locals and so on) that contain arguments (name = value). Values have types (string, number, bool, list, set, map, object, tuple), you refer to other objects with addresses like var.region or aws_vpc.main.id, and you compute values with operators, conditionals, for expressions and built-in functions. terraform console lets you try any expression before you put it in code.
You do not need to be a programmer to write Terraform, but you do need to read HCL fluently. This part covers the syntax you see in every configuration, and every expression output below comes from a real terraform console session with Terraform 1.17.
Prerequisites
- Terraform installed (Part 2).
- An empty directory to experiment in. No cloud account is needed.
Files and Directories
Terraform reads every file ending in .tf (or .tf.json, the same language in JSON form) in the current directory and treats them as one configuration. The order of files and of blocks inside them does not matter, and subdirectories are ignored unless you call them as modules. A few other file types have special meaning:
| File | Purpose |
|---|---|
*.tf, *.tf.json |
Configuration |
terraform.tfvars, *.auto.tfvars |
Variable values, loaded automatically (Part 6) |
*.tftest.hcl |
Tests for terraform test (Part 10) |
.terraform.lock.hcl |
Provider versions and checksums, written by init |
override.tf, *_override.tf |
Merged over the other files; rarely a good idea, because readers miss them |
A common layout splits a small project into main.tf, variables.tf, outputs.tf, providers.tf and versions.tf. That is a convention for humans; Terraform does not care. The post How to Split a Terraform File shows the options.
Blocks and Arguments
# Block type "resource" with two labels: the resource type and a local name
resource "aws_instance" "web" {
# Arguments: name = expression
ami = var.ami_id
instance_type = "t3.micro"
# A nested block
root_block_device {
volume_size = 20
}
# An argument whose value is a map
tags = {
Name = "web-server"
}
}
- A block has a type (
resource), zero or more labels ("aws_instance","web") and a body in braces. Nested blocks such asroot_block_deviceuse the same syntax without=. - An argument assigns the value of an expression to a name. Each argument appears once per block.
- Identifiers (names of arguments, blocks, variables) contain letters, digits, underscores and hyphens and do not start with a digit.
- Comments:
#and//start a single-line comment,/* ... */spans lines.#is the idiomatic choice.
terraform fmt aligns the = signs, fixes indentation (two spaces) and normalizes spacing. Run it before every commit; terraform fmt -check fails in CI when a file needs formatting.
The top-level block types are:
| Block | Example | Covered in |
|---|---|---|
terraform |
required_version, required_providers, backend |
Part 2, Part 9 |
provider |
provider "aws" { region = "eu-west-1" } |
Part 4 |
resource, data, ephemeral |
infrastructure objects, lookups, temporary values | Part 4, Part 10 |
variable, output, locals |
inputs, results, named expressions | Part 6 |
module |
reusable groups of resources | Part 7 |
moved, import, removed |
refactoring and adopting existing objects | Part 5 |
check, action |
assertions after apply, provider-defined operations | Part 10, Part 4 |
A Playground With terraform console
terraform console evaluates expressions against your configuration. Create a directory with this main.tf. It declares one provider, two variables and some local values, and creates nothing:
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.9"
}
}
}
variable "environment" {
type = string
default = "staging"
}
variable "servers" {
type = map(object({
size = string
zone = string
}))
default = {
"web-1" = { size = "small", zone = "a" }
"web-2" = { size = "small", zone = "b" }
"db-1" = { size = "large", zone = "a" }
}
}
locals {
name_prefix = "shop-${var.environment}"
ports = [80, 443, 8080]
}
Run terraform init once (the provider is used for one example below), then start the console. Each line you type at the > prompt is evaluated and the result printed. Type exit or press Ctrl+D to leave.
Types and Values
Every value has a type. Terraform converts between compatible types automatically (the string "8080" becomes a number where a number is expected), and errors out on everything else.
| Type | Example | Notes |
|---|---|---|
string |
"t3.micro" |
Double quotes only. Escapes: \n, \t, \", \\ |
number |
20, 0.5 |
One numeric type for integers and decimals |
bool |
true, false |
|
list(...) |
["a", "b"] |
Ordered, indexed from 0, values of one type |
set(...) |
toset(["a", "b"]) |
Unordered, no duplicates; you cannot index it |
map(...) |
{ Name = "web", Env = "dev" } |
Keys are strings, values of one type |
object({...}) |
{ size = "small", zone = "a" } |
Fixed attribute names, each with its own type |
tuple([...]) |
["web", 2, true] |
Fixed length, each element with its own type |
null |
null |
"Not set": the argument behaves as if you left it out |
You write list(string), map(number) or object({ name = string, port = number }) in type constraints on variables, covered in Part 6. Literal values in code are tuples and objects, which convert to lists and maps when the context asks for them.
One more kind of value matters later: unknown values. An attribute that only exists after creation, such as a new server's IP address, is unknown during the plan and shows as (known after apply). Most expressions accept unknown values, but a few places, such as count and for_each, need values Terraform knows at plan time.
References
You refer to other objects by address:
| Expression | Refers to |
|---|---|
var.<name> |
an input variable |
local.<name> |
a local value |
<type>.<name>.<attribute> |
a managed resource, for example aws_vpc.main.id |
data.<type>.<name>.<attribute> |
a data source, for example data.aws_ami.ubuntu.id |
module.<name>.<output> |
an output of a child module |
path.module, path.root, path.cwd |
the module's directory, the root module's directory, the working directory |
terraform.workspace |
the current workspace name (Part 8) |
count.index, each.key, each.value, self |
only inside resources that use count, for_each or provisioners |
A reference also creates a dependency: if aws_subnet.public refers to aws_vpc.main.id, Terraform creates the VPC first.
Strings and Templates
${...} inserts the result of an expression into a string. %{ ... } adds if and for directives:
A reference on its own needs no quotes: write subnet_id = aws_subnet.public.id, not "${aws_subnet.public.id}". The quoted form still works, but it is a leftover from Terraform 0.11.
For multi-line text, use a heredoc. The <<- form strips the indentation, so the text can follow your code's indentation:
locals {
user_data = <<-EOT
#!/bin/bash
apt-get update
apt-get install -y nginx
EOT
}
For anything longer than a few lines, put the text in a separate template file and render it with templatefile() (shown below).
Operators and Conditionals
Arithmetic (+ - * / %), comparison (== != < > <= >=) and logic (&& || !) work as in most languages. The conditional expression is condition ? value_if_true : value_if_false:
Both results of a conditional must have compatible types. var.enabled ? 1 : 0 is the classic way to create a resource only sometimes (count = var.enabled ? 1 : 0), shown in Part 4.
for Expressions and Splats
A for expression builds a new list (in [ ]) or map (in { }) from another collection, with an optional if filter:
- Iterating over a map gives you the key and the value (
name, s). Map keys come out in lexical order, which is whydb-1is first. - The
...after the value in the fourth expression groups values with the same key into a list. Without it, two servers in zoneawould be an error. [*]is a splat:list[*].attris short for[for x in list : x.attr]. You see it often withcount, for exampleaws_instance.web[*].id.- The console writes
tolist([...])when a value is a real list, as opposed to the tuple that a[ ]literal or aforexpression produces. In code you rarely have to care: Terraform converts between the two.
Built-in Functions
Terraform has more than a hundred built-in functions, grouped into string, numeric, collection, encoding, filesystem, date and time, hash and crypto, IP network, and type conversion functions. You cannot write your own in plain HCL, but providers can ship extra ones (below). These are the ones you use most:
Some notes on these:
mergecombines maps; later maps win on duplicate keys. It is how you add resource-specific tags to a set of common tags.tryreturns the first argument that does not produce an error. Use it for optional attributes, not to hide real mistakes. Its siblingcanreturnstrueorfalseand is useful in variable validation.cidrsubnet(prefix, newbits, netnum)carves subnets out of a network: 8 extra bits turn a/16into/24s, andnetnumpicks which one.cidrsubnetsallocates several consecutive ranges of different sizes at once, here two/24s and then a/20that starts at the next free/20boundary.lookup(map, key, default)still exists, butvar.map[key]ortry(var.map[key], default)reads better. The concatenation of lists is covered in more detail in How to Concatenate Lists in Terraform.
Rendering Template Files
templatefile(path, vars) reads a file and renders it with the variables you pass. The template can use ${...} and %{ for } directives:
server {
listen 80;
server_name ${domain};
%{ for port in backend_ports ~}
# backend on port ${port}
%{ endfor ~}
}
The ~ in %{ for ... ~} strips the newline after the directive, so the output has no blank lines where the directives were:
Name template files *.tftpl so editors recognize them. Use templatefile for user data scripts, configuration files and policy documents, and jsonencode or yamlencode when the output is JSON or YAML: building JSON by string concatenation breaks on the first quote in a value.
Provider-Defined Functions
Since Terraform 1.8, providers can ship their own functions. You call them with provider::<provider name>::<function> and the provider must be in required_providers. The local provider has direxists:
Bigger providers ship more useful ones, for example provider::aws::arn_parse() and provider::aws::arn_build() in the AWS provider.
Dynamic Blocks
Some resources take repeated nested blocks. A dynamic block generates them from a collection:
variable "ingress_ports" {
type = list(number)
default = [80, 443]
}
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = var.ingress_ports
content {
description = "Allow TCP ${ingress.value}"
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
The label of the dynamic block (ingress) is the name of the nested block to generate, and inside content the iterator has the same name: ingress.value is the current element (and ingress.key its index or map key). Use dynamic blocks sparingly: a plain list of nested blocks is easier to read when the number of items is fixed.
For AWS security groups specifically, the provider now recommends one resource per rule (aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule) over inline ingress and egress blocks, because inline rules and separate rule resources fight each other. You create those with for_each, shown in Part 4.
Common Mistakes
The console reports errors the same way plan does, which makes it a quick way to understand them:
- Using
+to join strings.+is only for numbers. Use a template:"web-${count.index}". - Reading a key that may not exist. Indexing a map with a missing key is an error. Use
try()or check withcontains(keys(...), key). - Single quotes. HCL strings use double quotes only.
- Mixing up lists and sets.
for_eachneeds a set or a map, so wrap a list intoset(). You cannot index a set with[0]. - Building JSON or YAML with string templates. Use
jsonencodeandyamlencode.
Summary
- HCL is blocks with labels and arguments;
terraform fmtkeeps the style consistent. - Values have types, and some values are unknown until apply.
- References such as
var.x,local.yandaws_vpc.main.idconnect blocks and define the order. - Templates, conditionals,
forexpressions, splats and functions compute values;terraform consoleis the place to try them. dynamicblocks generate repeated nested blocks from a collection.
In Part 4 you use this language to create real resources.
Keep Learning
Tags
Found an issue?