Provisioners & null_resource: The Last Resort
HashiCorp’s Own Warning — And Why You Should Listen
HashiCorp explicitly documents provisioners as a “last resort.” This isn’t just a suggestion buried in footnotes — it’s a design philosophy statement. Provisioners break the declarative model. You’re embedding imperative scripts inside a declarative tool, and the consequences are predictable: hidden dependencies, unpredictable failures, non-idempotent operations, and state problems.
And yet, you’ll encounter them. Legacy codebases use them. Some edge cases genuinely require them. The exam tests them. So you need to understand what they are, how they work, and — most importantly — why you should avoid them for anything that has a declarative alternative.
What Provisioners Are — And What They’re Not
Provisioners execute scripts on local or remote machines as part of resource creation or destruction. They run once — at create time by default, or at destroy time if explicitly configured.
They are NOT:
– A configuration management system (use Ansible, Puppet, Chef for that)
– Idempotent (running twice may produce different results or break things)
– Visible in terraform plan (you can’t preview what a provisioner will do)
– Tracked by state (Terraform doesn’t know if the script’s effects still hold)
Three types exist: local-exec, remote-exec, and file.
local-exec — Running Commands Where Terraform Runs
Executes a command on the machine running Terraform — your laptop, a CI runner, a remote execution agent.
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
provisioner "local-exec" {
command = "echo ${self.public_ip} >> inventory.txt"
}
}
self refers to the parent resource. After the instance is created, Terraform runs that echo command locally. Common uses:
- Writing IP addresses to Ansible inventory files
- Triggering external deployment pipelines
- Running API calls to register resources with external systems
With working directory and environment variables:
provisioner "local-exec" {
command = "./deploy.sh"
working_dir = "${path.module}/scripts"
environment = {
APP_VERSION = var.app_version
REGION = var.region
}
}
Triggering Ansible after Terraform creates an instance:
provisioner "local-exec" {
command = <<-EOT
ansible-playbook \
-i '${self.public_ip},' \
-u ubuntu \
--private-key ~/.ssh/id_rsa \
playbooks/web.yml
EOT
}
remote-exec — Running Commands on the Remote Resource
Executes commands on the resource itself via SSH or WinRM:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
key_name = aws_key_pair.deployer.key_name
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",
"sudo systemctl enable nginx",
"sudo systemctl start nginx",
]
}
}
The connection block tells Terraform how to reach the instance. remote-exec runs the commands over that SSH connection. It also supports a script file:
provisioner "remote-exec" {
script = "${path.module}/scripts/setup.sh"
}
The fundamental problem with remote-exec: it requires SSH access, which means the instance must have a public IP or be reachable from the Terraform runner. In modern architectures with private subnets, VPN-only access, or bastion hosts, this connectivity requirement is a significant operational burden.
file Provisioner — Copying Files to a Remote Resource
Copies files or directories from the machine running Terraform to the remote resource over SSH or WinRM:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
key_name = aws_key_pair.deployer.key_name
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
# Copy a single file
provisioner "file" {
source = "conf/app.conf"
destination = "/etc/myapp/app.conf"
}
# Copy an entire directory
provisioner "file" {
source = "scripts/" # trailing slash = directory contents
destination = "/opt/myapp/scripts"
}
# Write inline content (no local file needed)
provisioner "file" {
content = "DATABASE_URL=${aws_db_instance.main.endpoint}"
destination = "/etc/myapp/.env"
}
}
Three modes:
– source (file path) — copies a single local file to the remote destination
– source (directory path) — copies a directory. Trailing / copies the contents into the destination. No trailing slash copies the directory itself.
– content — writes a string directly to a remote file. Useful for generating config files from Terraform values without creating a local temp file.
Like remote-exec, the file provisioner requires a connection block. No SSH access means no file transfer.
Destroy-Time Provisioners
Run when a resource is being destroyed:
resource "aws_instance" "web" {
...
provisioner "local-exec" {
when = destroy
command = "deregister-from-lb.sh ${self.id}"
}
}
Destroy-time provisioners execute before the resource is destroyed. If the provisioner fails, the resource is not destroyed — Terraform leaves it in place and marks it as tainted. This can leave you in a state where the resource can’t be destroyed without manual intervention.
Failure Behavior and Tainting
By default, if a provisioner fails, the apply fails and the resource is marked as tainted in state. A tainted resource will be destroyed and recreated on the next apply — regardless of whether you’ve fixed the provisioner.
Override with on_failure:
provisioner "local-exec" {
command = "some-command-that-might-fail"
on_failure = continue # Warn but don't fail the apply
}
on_failure = continue means: if the provisioner fails, issue a warning but keep going. The resource is NOT tainted. Use this for non-critical operations like sending notifications or updating inventories.
null_resource and terraform_data — Actions Without Resources
Sometimes you need to run a script that isn’t tied to any specific cloud resource — updating a load balancer config, bootstrapping a cluster, running a database migration. That’s what null_resource is for:
resource "null_resource" "configure_cluster" {
triggers = {
cluster_id = aws_ecs_cluster.main.id
}
provisioner "local-exec" {
command = "aws ecs update-service --cluster ${aws_ecs_cluster.main.name} ..."
}
}
triggers is a map of values. When any trigger value changes, the null_resource is destroyed and recreated — re-running its provisioners. If triggers don’t change, the null_resource is untouched.
terraform_data — The Modern Replacement (Terraform 1.4+):
resource "terraform_data" "bootstrap" {
triggers_replace = [aws_instance.web.id]
provisioner "local-exec" {
command = "bootstrap.sh ${aws_instance.web.public_ip}"
}
}
terraform_data does the same thing as null_resource but with a cleaner API and no separate provider required (no hashicorp/null provider needed). For new configs, prefer terraform_data.
Why Provisioners Are a “Last Resort” — The Real Problems
| Problem | Explanation |
|---|---|
| Not idempotent | Running the same script twice may install packages again, create duplicate entries, or fail entirely |
| State blind | Terraform has no way to know if the script’s effects are still in place after apply |
| Failure = tainted resource | A failed provisioner taints the entire resource, potentially causing unnecessary destroy/recreate cycles |
| SSH dependency | remote-exec and file require SSH access — problematic in private networks, bastion-only access, or containerized environments |
| Invisible in plan | terraform plan doesn’t show provisioner actions, so you can’t preview what scripts will execute |
Better alternatives for each common use case:
– VM bootstrapping → Cloud-init / user data scripts (no SSH required, runs at boot, cloud-native)
– Ongoing configuration → Ansible, Puppet, Chef (purpose-built for configuration management)
– Pre-built images → Packer (build AMIs with software pre-installed, then launch with Terraform)
– Post-deploy actions → AWS SSM Run Command, GCP OS Config (managed, no SSH)
Key Takeaways
Provisioners run once at creation time (or destroy time). They do NOT re-run when the resource is updated. If you change a resource attribute, the provisioner doesn’t fire again. This means provisioners can’t maintain configuration over time — they only set initial state. For ongoing configuration management, you need a dedicated tool.
null_resource requires the null provider. terraform_data is built into Terraform 1.4+ with no extra provider. For new code, always use terraform_data. For existing code, the migration is straightforward.
If you’re using remote-exec to install software on a VM, stop and reconsider. Build the AMI with Packer (or equivalent) with the software pre-installed. Terraform launches the ready image. No SSH dependency, no provisioner fragility, no deviation between instances. Your infrastructure becomes truly immutable — machines are replaced, never modified in place.

