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 reference | Value |
|---|---|
path.module | directory of the module containing the expression |
path.root | directory of the root module |
path.cwd | the process working directory, usually equals path.root |
terraform.workspace | current workspace name |
terraform.applying | true 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 oftry()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
| Function | Example | Result |
|---|---|---|
upper / lower | upper("hi") | "HI" |
title / strrev | title("a b") | "A B" |
trimspace(s) | trimspace(" hi ") | "hi" |
trim(s, cutset) | trim("!!hi!!", "!") | "hi" |
trimprefix / trimsuffix | trimprefix("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 / endswith | startswith("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
| Function | Example | Result |
|---|---|---|
abs / signum | abs(-5) | 5 |
ceil / floor | ceil(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
| Function | Example | Result |
|---|---|---|
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 / compact | compact(["a","",null]) | ["a"] |
slice(l, s, e) | slice([1,2,3,4], 1, 3) | [2,3] |
reverse / sort | sort(["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 / values | sorted 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 / anytrue | alltrue([true, false]) | false |
setunion / setintersection / setsubtract | set algebra | — |
setproduct(a, b) | cartesian product, for nested for_each | — |
coalescelist(...) | first non-empty list | — |
Encoding, Hashing and Files
| Function | Use |
|---|---|
jsonencode / jsondecode | JSON in and out, use for policies and user data |
yamlencode / yamldecode | YAML in and out |
csvdecode(s) | CSV string to a list of objects |
base64encode / base64decode | base64, ASCII only |
base64gzip(s) | gzip then base64, for oversized user data |
textencodebase64 / textdecodebase64 | base64 with an explicit charset |
md5 / sha1 / sha256 / sha512 | hex digests |
base64sha256 / base64sha512 | base64 digests, what Lambda wants |
filemd5 / filesha256 / filebase64sha256 | hash 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 / pathexpand | path 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
| Function | Notes |
|---|---|
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.
localsblocks 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.regionadds a hop and nothing else.
Sensitive and Ephemeral in Expressions
| Function | Behavior |
|---|---|
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"))