Skip to main content

Terraform Provisioners — When (and When Not) to Use Them

· 7 min read
Goel Academy
DevOps & Cloud Learning Hub

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 Terraform
  • remote-exec — runs a command on the newly created resource via SSH or WinRM
  • file — 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:

  1. Not declarative. Terraform's strength is declarative infrastructure. Provisioners are imperative scripts that Terraform cannot reason about, plan, or diff.
  2. 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.
  3. Fragile connections. SSH may not be available immediately after instance launch. Firewalls, security groups, or boot delays can cause failures.
  4. No drift detection. Terraform has no way to know if the configuration applied by a provisioner has drifted.
  5. State coupling. If a provisioner fails halfway, the resource may be partially configured with no clean way to retry.

Better Alternatives

ApproachBest ForHow It Works
cloud-init / user_dataInitial server setupCloud provider runs the script at first boot — no SSH needed
PackerGolden imagesBuild a fully configured AMI/image, then launch it with Terraform
AnsibleOngoing configurationRun Ansible separately after Terraform creates the infrastructure
Kubernetes + HelmContainer workloadsTerraform creates the cluster, Helm deploys the applications
AWS SSM Run CommandRemote execution on AWSNo 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-exec to 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-exec with Ansible — Terraform creates the infrastructure, then kicks off Ansible for configuration. This is a common pattern in teams transitioning from Ansible-only workflows.
  • remote-exec as 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.