Terraform Provisioners: A Last Resort, Not a First Choice

As part of my ongoing Terraform learning journey, today I explored a topic that often confuses beginners and is frequently misused in real projects — Terraform Provisioners.
At first glance, provisioners look like a convenient way to “configure” resources after creation. But once I understood how and when they run, it became clear why Terraform itself advises us to be very careful with them.
This learning day was less about writing code and more about understanding Terraform philosophy and best practices.
What Are Terraform Provisioners?
Terraform is built to manage infrastructure declaratively — you describe the desired state, and Terraform makes it happen. Provisioners are different.
They allow Terraform to run commands or scripts at specific points in a resource’s lifecycle, mainly:
when a resource is created
or when it is destroyed
Because provisioners introduce imperative behavior into a declarative tool, Terraform clearly labels them as a last-option mechanism, not a default solution.
Why Provisioners Are Considered a “Last Resort”
Terraform is designed to manage infrastructure state, not configuration steps.
Provisioners:
Are imperative (command-based)
Are not tracked well in Terraform state
Can fail silently or partially
Do not rerun automatically on every change
Because of this, HashiCorp clearly states:
Use provisioners only when no better alternative exists.
Better alternatives usually include:
user_data/ cloud-initConfiguration management tools (Ansible, Chef, Puppet)
Immutable images (AMI baking with Packer).
Types of Provisioners I Worked With
Terraform supports multiple provisioners, but the most commonly used ones fall into three categories.
1. local-exec – Runs on Your Machine
The local-exec provisioner runs where Terraform itself is executed, not on the cloud resource.
When it makes sense
Calling APIs or webhooks
Writing values to local files
Sending notifications
Updating external systems
Example
provisioner "local-exec" {
command = "echo 'Local-exec: created instance ${self.id} with IP ${self.public_ip}'"
}
This does not run on the EC2 instance.
It runs locally where Terraform is executed.
Key takeaway
This provisioner never touches the EC2 instance or VM. It’s purely local.
2. remote-exec – Runs on the Resource
The remote-exec provisioner connects to the created resource and executes commands directly on it.
Typical uses
Installing basic packages
Running simple setup commands
Starting services like Nginx
Example
provisioner "remote-exec" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y nginx",
"sudo systemctl start nginx"
]
}
This is useful for quick demos or labs, but risky for production.
Important limitation
It requires a working SSH or WinRM connection, and failures can easily break your Terraform run.
3. file – Copies Files to the Resource
The file provisioner is used to transfer files from your local system to the remote machine.
Common scenarios
Uploading scripts before execution
Copying configuration files
Moving certificates or binaries
Example
provisioner "file" {
source = "${path.module}/main.tf"
destination = "/tmp/welcome.tf"
}
It is usually paired with remote-exec to run the copied files.
Connection Block (Very Important)
For remote-exec and file provisioners, a connection block is mandatory.
connection {
type = "ssh"
user = "ubuntu"
private_key = file("${path.module}/yourpemfile.pem")
host = self.public_ip
}
Without this, Terraform won’t know how to connect to the resource.
When Do Provisioners Actually Run?
One of the most important lessons from today:
Provisioners run only during resource creation.
They do not run:
during
terraform planduring normal updates
when provisioner code changes
on every
terraform apply
Options to re-run:
terraform taint aws_instance.demo
or
terraform apply -replace=aws_instance.demo
Failure Behavior
By default, if a provisioner fails:
Resource creation is considered failed
Resource is marked as tainted
Next apply will destroy and recreate it
You can override this behavior:
provisioner "remote-exec" {
inline = ["some-command"]
on_failure = continue
}
Destroy-Time Provisioners
Provisioners can also run during resource destruction.
provisioner "local-exec" {
when = destroy
command = "echo 'Cleaning up ${self.id}'"
}
Useful for cleanup tasks, logging, or deregistration.
Full Working Example (What I Implemented)
Here’s the complete Terraform code I used to understand provisioners practically.
resource "aws_instance" "example" {
ami = data.aws_ami.example.id
instance_type = "t2.micro"
key_name = "your-key-name"
vpc_security_group_ids = [aws_security_group.example.id]
tags = {
Name = "HelloWorld"
}
connection {
type = "ssh"
user = "ubuntu"
private_key = file("${path.module}/yourpemfile.pem")
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"sudo apt-get update",
"sudo apt install nginx -y",
"sudo systemctl start nginx"
]
}
}
resource "aws_security_group" "example" {
name = "sg"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Once the instance was created, Nginx was automatically installed and started using remote-exec.
📺 Video That Helped Me Understand this concept:
Final Thoughts
Today’s learning changed how I look at Terraform.
Provisioners are not “bad,” but they are not Terraform’s strength. They exist for edge cases — not as a replacement for proper configuration management or automation tools.
Understanding provisioners helped me better understand Terraform’s design mindset, which is just as important as writing code.



