Skip to content

Provisioners and Functions

First PublishedByAtif Alam

Terraform includes a rich library of built-in functions you can use in expressions. You cannot define custom functions — but the built-in set covers most needs.

upper("hello") # "HELLO"
lower("HELLO") # "hello"
trimspace(" hello ") # "hello"
replace("hello world", " ", "-") # "hello-world"
format("server-%03d", 5) # "server-005"
join(", ", ["a", "b", "c"]) # "a, b, c"
split(",", "a,b,c") # ["a", "b", "c"]
substr("terraform", 0, 5) # "terra"
min(5, 3, 9) # 3
max(5, 3, 9) # 9
ceil(4.2) # 5
floor(4.8) # 4
abs(-42) # 42
length(["a", "b", "c"]) # 3
contains(["a", "b"], "a") # true
lookup({a = 1, b = 2}, "a", 0) # 1 (default 0 if key missing)
merge({a = 1}, {b = 2}) # {a = 1, b = 2}
flatten([["a", "b"], ["c"]]) # ["a", "b", "c"]
distinct(["a", "b", "a"]) # ["a", "b"]
keys({a = 1, b = 2}) # ["a", "b"]
values({a = 1, b = 2}) # [1, 2]
zipmap(["a", "b"], [1, 2]) # {a = 1, b = 2}
element(["a", "b", "c"], 1) # "b"
concat(["a"], ["b", "c"]) # ["a", "b", "c"]
jsonencode({name = "web", port = 8080}) # '{"name":"web","port":8080}'
jsondecode("{\"name\":\"web\"}") # {name = "web"}
base64encode("hello") # "aGVsbG8="
base64decode("aGVsbG8=") # "hello"
yamlencode({replicas = 3}) # "replicas: 3\n"
file("scripts/init.sh") # read file contents as string
fileexists("scripts/init.sh") # true/false
templatefile("templates/config.tpl", { port = 8080 }) # render template

templatefile is particularly useful for generating config files:

templates/config.tpl
server {
listen ${port};
server_name ${hostname};
}
resource "aws_instance" "web" {
user_data = templatefile("templates/userdata.sh", {
db_host = aws_db_instance.main.address
db_port = 5432
})
}
cidrsubnet("10.0.0.0/16", 8, 1) # "10.0.1.0/24"
cidrsubnet("10.0.0.0/16", 8, 2) # "10.0.2.0/24"
cidrhost("10.0.1.0/24", 5) # "10.0.1.5"

cidrsubnet is essential for dynamically computing subnet CIDRs from a VPC CIDR.

tostring(42) # "42"
tonumber("42") # 42
tolist(toset(["a", "b"])) # ["a", "b"]
toset(["a", "b", "a"]) # toset(["a", "b"])
tomap({a = 1, b = 2}) # {a = 1, b = 2}

Use terraform console to experiment:

Terminal window
terraform console
> cidrsubnet("10.0.0.0/16", 8, 3)
"10.0.3.0/24"
> format("env-%s-%02d", "prod", 1)
"env-prod-01"

Provisioners execute scripts or commands on a resource after it’s created (or before it’s destroyed). They are a last resort — Terraform’s documentation explicitly warns against using them when better alternatives exist.

  • Not declarative — Provisioners run imperative commands; Terraform can’t track what they did.
  • Not in state — If a provisioner script changes, Terraform doesn’t know. Plan won’t show the difference.
  • Fragile — SSH connections fail, scripts error out, and partial runs leave resources in an unknown state.
  • Better alternatives exist — Cloud-init / user_data for boot scripts, Ansible for configuration management, Packer for pre-built images.
  • Bootstrapping — A quick one-liner to register a node with a config management tool.
  • One-time setup — Running a database migration that can’t be done any other way.
  • Legacy systems — When there’s truly no API or provider support for what you need.

Runs a command on the machine where Terraform is running:

resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
provisioner "local-exec" {
command = "echo ${self.public_ip} >> hosts.txt"
}
}

Runs a command on the remote resource via SSH or WinRM:

resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
key_name = "my-key"
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y nginx",
]
}
}

Copies files or directories to the remote resource:

provisioner "file" {
source = "configs/app.conf"
destination = "/etc/app.conf"
}

Run when a resource is being destroyed:

provisioner "local-exec" {
when = destroy
command = "echo 'Destroying ${self.id}' >> destroy.log"
}

The null_resource was traditionally used to run provisioners without a real resource. As of Terraform 1.4, terraform_data is the recommended replacement:

resource "terraform_data" "run_script" {
triggers_replace = [
aws_instance.web.id,
timestamp(),
]
provisioner "local-exec" {
command = "python3 scripts/configure.py ${aws_instance.web.public_ip}"
}
}
  • triggers_replace — When any trigger value changes, the resource is replaced and provisioners re-run.
  • No provider dependency — it’s built into Terraform core.

Still works but requires the hashicorp/null provider:

resource "null_resource" "run_script" {
triggers = {
instance_id = aws_instance.web.id
}
provisioner "local-exec" {
command = "python3 scripts/configure.py ${aws_instance.web.public_ip}"
}
}

Prefer terraform_data for new configurations.

NeedBetter Approach
Install packages on bootuser_data / cloud-init
Configure servers after creationAnsible (run after terraform apply)
Pre-install software in imagesPacker (build AMI, reference in Terraform)
Run database migrationsCI/CD pipeline step (after deploy)
Register with monitoringProvider-specific resources (e.g. Datadog provider)
  • Terraform has 100+ built-in functions — use terraform console to explore them.
  • templatefile is the cleanest way to generate config files from templates.
  • cidrsubnet is essential for computing subnet CIDRs dynamically.
  • Avoid provisioners when possible — prefer user_data, Ansible, or Packer.
  • Use terraform_data instead of null_resource for trigger-based provisioner execution.
  • If you must use a provisioner, prefer local-exec over remote-exec (less fragile).