Terraform Provisioners — When (and When Not) to Use Them
Terraform is a provisioning tool, not a configuration management tool. It creates infrastructure — VMs, networks, databases — but it was never designed to install packages, configure services, or manage files on running machines. Provisioners exist as an escape hatch for those cases, and HashiCorp explicitly recommends using them only as a last resort.
What Are Provisioners?
Provisioners are blocks you attach to a resource to execute scripts or copy files after the resource is created (or before it is destroyed). They run once during the initial creation and are not re-executed on subsequent applies unless the resource is tainted or recreated.
Terraform ships three built-in provisioners:
local-exec— runs a command on the machine running Terraformremote-exec— runs a command on the newly created resource via SSH or WinRMfile— copies files or directories from the local machine to the remote resource
local-exec Provisioner
local-exec runs a command on the machine executing terraform apply. It is the most common provisioner because it does not require SSH access to the target resource.
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
provisioner "local-exec" {
command = "echo ${self.private_ip} >> private_ips.txt"
}
}
A more practical use — triggering an Ansible playbook after an instance is created:
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = "t3.medium"
key_name = var.key_name
provisioner "local-exec" {
command = <<-EOT
sleep 30
ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook \
-i '${self.public_ip},' \
-u ubuntu \
--private-key ${var.private_key_path} \
playbooks/setup.yml
EOT
}
}
The sleep 30 is a common workaround — the EC2 instance needs time to boot and start the SSH daemon before Ansible can connect. This is fragile, which is exactly why provisioners are discouraged.
remote-exec Provisioner
remote-exec connects to the resource via SSH or WinRM and runs commands directly on it:
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
key_name = var.key_name
connection {
type = "ssh"
user = "ubuntu"
private_key = file(var.private_key_path)
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"sudo apt-get update -y",
"sudo apt-get install -y nginx",
"sudo systemctl enable nginx",
"sudo systemctl start nginx"
]
}
}
You can also reference a script file instead of inline commands:
provisioner "remote-exec" {
script = "scripts/bootstrap.sh"
}
Or multiple scripts:
provisioner "remote-exec" {
scripts = [
"scripts/install-deps.sh",
"scripts/configure-app.sh",
"scripts/start-service.sh"
]
}
file Provisioner
The file provisioner copies files or directories from the local machine to the remote resource:
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
key_name = var.key_name
connection {
type = "ssh"
user = "ubuntu"
private_key = file(var.private_key_path)
host = self.public_ip
}
provisioner "file" {
source = "configs/nginx.conf"
destination = "/tmp/nginx.conf"
}
provisioner "remote-exec" {
inline = [
"sudo mv /tmp/nginx.conf /etc/nginx/nginx.conf",
"sudo systemctl reload nginx"
]
}
}
Notice the pattern: file copies to /tmp first because the SSH user typically does not have write access to system directories, then remote-exec moves the file with sudo.
Connection Blocks
Both remote-exec and file require a connection block that tells Terraform how to reach the resource.
SSH connection (Linux):
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
port = 22
timeout = "5m"
}
WinRM connection (Windows):
connection {
type = "winrm"
user = "Administrator"
password = var.admin_password
host = self.public_ip
port = 5986
https = true
insecure = true
timeout = "10m"
}
The connection block can be placed inside the resource (applies to all provisioners) or inside a specific provisioner block (applies only to that provisioner).
Creation-Time vs Destroy-Time Provisioners
By default, provisioners run at creation time — when the resource is first created. You can also define destroy-time provisioners that run before Terraform destroys the resource:
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
# Runs at creation
provisioner "local-exec" {
command = "echo 'Instance ${self.id} created' >> deploy.log"
}
# Runs at destruction
provisioner "local-exec" {
when = destroy
command = "echo 'Instance ${self.id} being destroyed' >> deploy.log"
}
}
Destroy-time provisioners are useful for deregistering instances from load balancers, cleaning up DNS records, or sending notifications. But they have a limitation: if the resource is already gone (for example, manually deleted), the destroy provisioner cannot connect to it.
on_failure Behavior
By default, if a provisioner fails, Terraform marks the resource as tainted and the next apply will destroy and recreate it. You can change this behavior:
provisioner "remote-exec" {
inline = ["sudo apt-get install -y some-package"]
on_failure = continue # Ignore the error and mark as created
# on_failure = fail # Default — taint the resource
}
Use on_failure = continue sparingly. It can leave resources in an inconsistent state — created but not properly configured.
Why HashiCorp Discourages Provisioners
The Terraform documentation itself says: "Provisioners are a Last Resort." Here is why:
- Not declarative. Terraform's strength is declarative infrastructure. Provisioners are imperative scripts that Terraform cannot reason about, plan, or diff.
- Run once. Provisioners run at creation time only. If you change the script, Terraform will not re-run it — you must taint the resource and recreate it.
- Fragile connections. SSH may not be available immediately after instance launch. Firewalls, security groups, or boot delays can cause failures.
- No drift detection. Terraform has no way to know if the configuration applied by a provisioner has drifted.
- State coupling. If a provisioner fails halfway, the resource may be partially configured with no clean way to retry.
Better Alternatives
| Approach | Best For | How It Works |
|---|---|---|
cloud-init / user_data | Initial server setup | Cloud provider runs the script at first boot — no SSH needed |
| Packer | Golden images | Build a fully configured AMI/image, then launch it with Terraform |
| Ansible | Ongoing configuration | Run Ansible separately after Terraform creates the infrastructure |
| Kubernetes + Helm | Container workloads | Terraform creates the cluster, Helm deploys the applications |
| AWS SSM Run Command | Remote execution on AWS | No SSH needed — uses the SSM agent |
The user_data approach is the most natural replacement for remote-exec:
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
user_data = <<-EOF
#!/bin/bash
apt-get update -y
apt-get install -y nginx
systemctl enable nginx
systemctl start nginx
EOF
user_data_replace_on_change = true
tags = {
Name = "web-server"
}
}
Unlike remote-exec, user_data does not require SSH access, runs as root, and is executed by the cloud provider's init system. Setting user_data_replace_on_change = true tells Terraform to recreate the instance when the script changes.
For more complex configuration, use templatefile to inject variables:
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
user_data = templatefile("scripts/init.sh", {
db_host = aws_db_instance.main.address
db_password = var.db_password
environment = var.environment
})
}
When Provisioners ARE Appropriate
Despite the warnings, there are legitimate use cases:
local-execto trigger external systems — updating a CMDB, notifying a Slack channel, or running a one-time migration script that only makes sense in the context of resource creation.local-execwith Ansible — Terraform creates the infrastructure, then kicks off Ansible for configuration. This is a common pattern in teams transitioning from Ansible-only workflows.remote-execas a health check — waiting for a service to be reachable before marking the resource as created.
The rule of thumb: if the action you want to perform can be expressed as a cloud resource or handled by user_data, do not use a provisioner. If it truly requires running a command that has no Terraform resource equivalent, a provisioner is acceptable.
Wrapping Up
Provisioners are Terraform's escape hatch — powerful but undisciplined. local-exec is relatively safe for triggering external workflows. remote-exec and file introduce SSH dependencies and fragile timing. For server configuration, prefer cloud-init, Packer images, or dedicated configuration management tools like Ansible. Use provisioners when nothing else fits, and keep them as simple as possible.
Next, we will dive into Terraform built-in functions — the string, collection, encoding, and math functions that make HCL expressions powerful and flexible.
