Terraform Cheatsheet

Expressions and Functions

Use this Terraform reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

References and Literals

Everything on the right-hand side of an = is an expression. These are the reference forms you can use.

# Literals
42            3.5           true          "hello"
["a", "b"]                  { key = "value" }
null                        # explicit absence, not the empty string

# References
var.instance_type           # input variable
local.name_prefix           # local value
aws_instance.web.public_ip  # managed resource attribute
aws_instance.web["a"].id    # for_each instance
aws_instance.web[0].id      # count instance
data.aws_ami.al2023.id      # data source
module.vpc.vpc_id           # module output
each.key / each.value       # inside a for_each block
count.index                 # inside a count block
self.private_ip             # only in provisioners and connection blocks
Path and metadata referenceValue
path.moduledirectory of the module containing the expression
path.rootdirectory of the root module
path.cwdthe process working directory, usually equals path.root
terraform.workspacecurrent workspace name
terraform.applyingtrue during apply, false during plan (1.10+)

Operators: + - * / %, == !=, < <= > >=, && || !. There is no bitwise arithmetic and no string concatenation operator, use interpolation or join().

Strings and Templates

name     = "server-${var.environment}-${count.index}"
endpoint = "https://${aws_lb.api.dns_name}/api"
literal  = "cost is $${amount}"     # $$ escapes an interpolation

# Indented heredoc: <<- strips the common leading whitespace
user_data = <<-EOT
  #!/bin/bash
  echo "ENV=${var.environment}" >> /etc/app.conf
  systemctl start myapp
EOT
# Template directives. The ~ trims the adjacent whitespace and newline.
hosts = <<-EOT
  %{ for ip in var.private_ips ~}
  ${ip} node
  %{ endfor ~}
EOT

acl = <<-EOT
  %{ if var.public }allow all%{ else }deny all%{ endif }
EOT
# templatefile(): render a file from disk
user_data = templatefile("${path.module}/init.tftpl", {
  env  = var.environment
  port = 8080
})

# templatestring() (1.9+): render a template that arrives at runtime
rendered = templatestring(data.aws_s3_object.tpl.body, { env = "prod" })

A .tftpl file uses exactly the same ${} interpolation and %{ for } / %{ if } directives as a heredoc. It cannot reference var or local directly, only the map you pass in.

Conditionals, try, can and coalesce

instance_type = var.is_prod ? "t3.large" : "t3.micro"
count         = var.enable_replica ? 2 : 0

# try(): first argument that evaluates without error
image = try(var.config.image, "nginx:latest")
port  = try(var.listeners[0].port, 8080)

# can(): true if the expression evaluates at all, for validation
condition = can(regex("^ami-", var.ami_id))

# coalesce(): first non-null, non-empty argument
name = coalesce(var.override_name, var.name, "default")

# one(): null for an empty list, the element for exactly one, error for more
vpc_id = one(data.aws_vpcs.selected.ids)

Both branches of a ?: must be the same type, and Terraform will try to convert them, so var.x ? 1 : "none" produces a confusing error rather than what you meant. Wrap tostring() around both if you really want strings.

For optional object attributes prefer optional(type, default) in the variable's type constraint over a chain of try() calls. It documents the shape and produces a real error message.

for Expressions and Splats

# list → list
upper_names = [for s in var.names : upper(s)]

# list → list, filtered
long_names  = [for s in var.names : s if length(s) > 5]

# map → map (note the => )
upper_tags  = { for k, v in var.tags : k => upper(v) }

# map → list
tag_strings = [for k, v in var.tags : "${k}=${v}"]

# list → map, keyed by an attribute
by_name = { for u in var.users : u.name => u }

# grouping mode: ... collects duplicates into a list per key
by_az = { for s in var.subnets : s.az => s.id... }

# resource instances → map of ids
instance_ids = { for k, v in aws_instance.app : k => v.id }
# Splat: shorthand for a single-level for expression
all_ips = aws_instance.web[*].private_ip
# same as: [for o in aws_instance.web : o.private_ip]

# Splats do not nest. Flatten a list-of-lists attribute:
all_sgs = flatten(aws_instance.web[*].vpc_security_group_ids)

# Legacy attribute-only splat, avoid in new code
old = aws_instance.web.*.private_ip

Splat works on lists and on count-based resources. It does not work on a for_each map, use values(aws_instance.web)[*].id or a for expression instead.

Function Reference

There is no way to define your own function in HCL. Providers can ship them (1.8+) and they are called as provider::aws::arn_parse(...).

Strings

FunctionExampleResult
upper / lowerupper("hi")"HI"
title / strrevtitle("a b")"A B"
trimspace(s)trimspace(" hi ")"hi"
trim(s, cutset)trim("!!hi!!", "!")"hi"
trimprefix / trimsuffixtrimprefix("ami-1", "ami-")"1"
chomp(s)strips trailing newlines—
split(sep, s)split(",", "a,b")["a","b"]
join(sep, list)join("-", ["a","b"])"a-b"
replace(s, sub, rep)replace("a-b", "-", "_")"a_b"
format(fmt, ...)format("%s-%02d", "app", 3)"app-03"
formatlist(fmt, list)formatlist("n-%s", ["a"])["n-a"]
substr(s, off, len)substr("hello", 1, 3)"ell"
indent(n, s)indents every line but the first—
startswith / endswithstartswith("hello", "he")true
strcontains(s, sub)strcontains("hello", "ell")true
regex(pat, s)returns match or capture groups—
regexall(pat, s)list of every match—
urlencode(s)percent-encodes for a query string—

Numbers and Sequences

FunctionExampleResult
abs / signumabs(-5)5
ceil / floorceil(1.2)2
max(...) / min(...)max(3, 1, 4)4
pow(x, y) / log(n, b)pow(2, 8)256
sum(list)sum([1, 2, 3])6
parseint(s, base)parseint("ff", 16)255
range(...)range(3)[0, 1, 2]

Collections

FunctionExampleResult
length(v)length([1,2,3])3
concat(a, b)concat(["a"], ["b"])["a","b"]
flatten(list)flatten([[1,2],[3]])[1,2,3]
distinct / compactcompact(["a","",null])["a"]
slice(l, s, e)slice([1,2,3,4], 1, 3)[2,3]
reverse / sortsort(["b","a"])["a","b"]
contains(l, v)contains(["a"], "a")true
index(l, v)position of the first match—
element(l, i)element(["a","b"], 2)"a" (wraps)
chunklist(l, n)chunklist([1,2,3], 2)[[1,2],[3]]
keys / valuessorted keys, values by key—
lookup(m, k, d)lookup(m, "x", "t3.micro")—
merge(m1, m2)later maps win—
zipmap(ks, vs)zipmap(["a"], [1]){a = 1}
matchkeys(vals, ks, want)filter one list by another—
transpose(m)swaps a map of lists inside out—
alltrue / anytruealltrue([true, false])false
setunion / setintersection / setsubtractset algebra—
setproduct(a, b)cartesian product, for nested for_each—
coalescelist(...)first non-empty list—

Encoding, Hashing and Files

FunctionUse
jsonencode / jsondecodeJSON in and out, use for policies and user data
yamlencode / yamldecodeYAML in and out
csvdecode(s)CSV string to a list of objects
base64encode / base64decodebase64, ASCII only
base64gzip(s)gzip then base64, for oversized user data
textencodebase64 / textdecodebase64base64 with an explicit charset
md5 / sha1 / sha256 / sha512hex digests
base64sha256 / base64sha512base64 digests, what Lambda wants
filemd5 / filesha256 / filebase64sha256hash a file without reading it into memory
bcrypt(s)new hash every run, so pair with ignore_changes
uuid() / uuidv5(ns, name)random, then deterministic
rsadecrypt(cipher, key)decrypt an RSA-encrypted blob
file(path) / fileexists(path)read a file, test for one
filebase64(path)read and base64 encode, for binaries
fileset(dir, glob)set of matching paths, for for_each over files
abspath / dirname / basename / pathexpandpath manipulation
# The canonical fileset + for_each upload pattern
resource "aws_s3_object" "site" {
  for_each = fileset("${path.module}/site", "**/*")

  bucket = aws_s3_bucket.site.id
  key    = each.value
  source = "${path.module}/site/${each.value}"
  etag   = filemd5("${path.module}/site/${each.value}")
}

Date and Time

FunctionNotes
timestamp()UTC RFC 3339, changes every apply, pair with ignore_changes
plantimestamp()fixed at plan time, stable across the plan and apply (1.5+)
formatdate(spec, ts)formatdate("YYYY-MM-DD", plantimestamp())
timeadd(ts, dur)timeadd(plantimestamp(), "24h")
timecmp(a, b)-1, 0 or 1

Type Conversion and Inspection

tostring(42)              # "42"
tonumber("42")            # 42
tobool("true")            # true
toset(["a", "a", "b"])    # a set, order is lost
tolist(toset(["a"]))      # ["a"]
tomap({ a = 1 })          # a map, all values coerced to one type

type(var.thing)           # a printable type name, for debugging
convert(var.x, list(string))   # explicit inline conversion (1.15+)

IP and CIDR

cidrsubnet("10.0.0.0/16", 8, 0)     # "10.0.0.0/24"
cidrsubnet("10.0.0.0/16", 8, 1)     # "10.0.1.0/24"
cidrsubnets("10.0.0.0/16", 8, 8, 4) # several at once, packed in order
cidrhost("10.0.0.0/24", 5)          # "10.0.0.5"
cidrnetmask("10.0.0.0/24")          # "255.255.255.0"

Local Values

Locals name an expression so it is computed once and read many times. They are not variables, nothing outside the module can set them.

locals {
  name_prefix = "${var.project}-${var.environment}"

  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
  }

  # Locals may reference earlier locals in the same or another block
  bucket_name = "${local.name_prefix}-assets"

  # A lookup table beats a chain of ternaries
  instance_size = {
    dev  = "t3.micro"
    prod = "m6i.large"
  }[var.environment]

  subnet_cidrs = [for i in range(var.az_count) :
    cidrsubnet(var.vpc_cidr, 8, i)]
}
  • Locals cannot be set from the CLI and cannot be overridden per environment. If a value differs by environment it is a variable.
  • locals blocks can appear more than once in a module and merge.
  • A local referencing itself, directly or through another local, is a cycle error.
  • Do not wrap a single variable in a local. local.region = var.region adds a hop and nothing else.

Sensitive and Ephemeral in Expressions

FunctionBehavior
sensitive(v)marks a value sensitive, and the mark spreads to everything derived from it
nonsensitive(v)strips the mark, only use where you have checked the value is safe to print
issensitive(v)true if the value carries the mark
ephemeralasnull(v)replaces ephemeral values with null, keeps the rest (1.10+)
# for_each cannot take a sensitive value, so a sensitive map key
# needs an explicit nonsensitive() and a reason in a comment
resource "aws_ssm_parameter" "cfg" {
  for_each = nonsensitive(var.config)   # keys are names, not secrets
  name     = each.key
  type     = "SecureString"
  value    = each.value
}

The mark is a display property. A sensitive value is still written to state in plaintext, and sensitive(v) protects nothing at rest. Reaching for nonsensitive() to silence an error usually means the value should not have been sensitive in the first place.

Patterns Worth Memorizing

# Toggle a resource
count = var.create_bucket ? 1 : 0

# Merge defaults with per-caller overrides
tags = merge(local.common_tags, var.extra_tags)

# Nested for_each over two lists
locals {
  pairs = { for p in setproduct(var.envs, var.regions) :
    "${p[0]}-${p[1]}" => { env = p[0], region = p[1] } }
}

# Flatten a map of lists into a flat list of objects for for_each
locals {
  rules = flatten([
    for sg, ports in var.sg_ports : [
      for p in ports : { sg = sg, port = p }
    ]
  ])
}

# Turn a list of objects into a keyed map so for_each is stable
by_id = { for r in local.rules : "${r.sg}-${r.port}" => r }

# Read a JSON config file into HCL
cfg = jsondecode(file("${path.module}/config.json"))