Terraform Provisioners: Theory, Internals, and Production Reality
Terraform provisioners are special blocks used to execute imperative commands during the lifecycle of a Terraform-managed resource. They are usually used when infrastructure must be bootstrapped after creation, such as installing packages, running scripts, registering a server, triggering Ansible, or performing cleanup actions before destruction.
However, provisioners are different from normal Terraform resources. Terraform resources are declarative and state-aware. Provisioners are imperative and mostly state-blind. This difference is the main reason provisioners must be used carefully in production environments.
| Terraform Resource | Terraform Provisioner |
|---|---|
| Declarative | Imperative |
| Tracked in Terraform state | Actions are not fully tracked in state |
| Supports drift detection | Limited or no drift detection |
| Predictable plan output | Commands run only during apply/destroy |
| Preferred for infrastructure lifecycle | Last resort for bootstrapping or integration |
Why Provisioners Exist
Terraform was designed to create and manage infrastructure resources such as virtual machines, networks, databases, IAM roles, DNS records, Kubernetes objects, and cloud services. But sometimes infrastructure creation alone is not enough.
Example situations:
- A VM is created, but you need to install Nginx.
- An EC2 instance is created, but you need to run a bootstrap script.
- A server is created, but you need to register it in monitoring.
- A resource is destroyed, but you need to call an external cleanup API.
- Terraform creates infrastructure, then Ansible configures software.
Provisioners were introduced to handle these edge cases. They provide a bridge between infrastructure provisioning and operational scripting.
Where Provisioners Fit
Terraform Resource Creation
│
▼
Cloud Provider Creates Infrastructure
│
▼
Provisioner Executes Script or Command
│
▼
External Configuration / Bootstrap Action
The Main Types of Terraform Provisioners
1. local-exec
The local-exec provisioner runs commands on the machine where Terraform is executed. This could be a
developer laptop, Jenkins agent, GitHub Actions runner, GitLab runner, or Terraform Cloud worker.
provisioner "local-exec" {
command = "echo ${self.public_ip} >> inventory.txt"
}
Common uses include generating local inventory files, triggering Ansible, sending notifications, calling APIs, or updating CMDB systems.
2. remote-exec
The remote-exec provisioner connects to a remote machine and executes commands on that machine. It usually
uses SSH for Linux and WinRM for Windows.
provisioner "remote-exec" {
inline = [
"sudo apt-get update -y",
"sudo apt-get install -y nginx",
"sudo systemctl start nginx"
]
}
This requires a connection block because Terraform needs to know how to access the remote server.
3. file Provisioner
The file provisioner copies files or directories from the Terraform machine to the remote resource.
provisioner "file" {
source = "app.conf"
destination = "/tmp/app.conf"
}
Provisioner Lifecycle: When Do They Run?
Provisioners usually run after a resource is created. These are called creation-time provisioners.
Creation-Time Provisioner Lifecycle
terraform apply
│
▼
Resource Created by Provider
│
▼
Terraform Receives Resource ID
│
▼
Provisioner Executes
│
▼
Provisioner Success?
│
├── Yes → State Updated Normally
│
└── No → Resource Marked Tainted
Provisioners can also run during destruction by using when = destroy.
provisioner "local-exec" {
when = destroy
command = "echo Resource is being destroyed"
}
What Happens If a Provisioner Fails?
This is one of the most important production concepts. If a creation-time provisioner fails, Terraform may mark the resource as tainted. A tainted resource is considered unreliable because Terraform created the resource, but the post-creation configuration did not complete successfully.
Tainted Resource Behavior
VM Created Successfully
│
▼
Provisioner Fails
│
▼
Terraform Marks Resource as Tainted
│
▼
Next terraform apply
│
▼
Terraform Plans Replacement
This can be dangerous in production. If a provisioner fails on a critical VM, Terraform may try to replace it in the next apply unless the issue is handled carefully.
Why Provisioners Are Considered a Last Resort
Provisioners are not bad, but they are risky because they operate outside Terraform's normal declarative model.
Terraform can track this:
resource "aws_instance" "web" {
instance_type = "t3.micro"
}
But Terraform cannot fully track this:
provisioner "remote-exec" {
inline = [
"sudo apt install nginx -y",
"sudo useradd appuser",
"sudo chmod 755 /app"
]
}
Terraform does not understand the final OS-level state created by these commands. It does not know the exact package version, user state, file permissions, or service health unless you explicitly model those elsewhere.
Production-Level Problem: Provisioners Are Not Idempotent by Default
Idempotency means running the same operation multiple times produces the same result safely. Terraform resources are designed to be idempotent. Shell scripts are not automatically idempotent.
Bad Non-Idempotent Script
provisioner "remote-exec" {
inline = [
"echo 'server configured' >> /etc/app.conf"
]
}
If this runs multiple times, it appends duplicate lines.
Better Idempotent Script
provisioner "remote-exec" {
inline = [
"grep -q 'server configured' /etc/app.conf || echo 'server configured' >> /etc/app.conf"
]
}
Production scripts must check current state before making changes.
local-exec: Production Explanation
local-exec is useful when Terraform needs to interact with systems outside the created resource.
Production Use Cases
- Triggering an Ansible playbook after VM creation.
- Generating a dynamic inventory file.
- Sending deployment notifications to Slack or Teams.
- Updating a CMDB with resource metadata.
- Calling internal approval or audit APIs.
- Running post-deployment validation scripts.
Example: Generate Ansible Inventory
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = "t3.micro"
provisioner "local-exec" {
command = "echo '${self.public_ip} ansible_user=ubuntu' >> inventory.ini"
}
}
Production Risk
This depends on the machine running Terraform. If Terraform runs on a laptop, the file is created on the laptop. If Terraform runs in GitHub Actions, the file is created inside the temporary runner. That means behavior changes based on execution environment.
remote-exec: Production Explanation
remote-exec is more fragile than local-exec because it requires remote connectivity.
For remote-exec to work, all of these must be ready:
- The VM must be created.
- The operating system must finish booting.
- SSH or WinRM must be running.
- Firewall rules must allow access.
- Security groups must allow access.
- Private key or password must be correct.
- The Terraform runner must be able to reach the VM network.
Production Warning
A cloud instance being created does not mean it is ready for SSH. Many remote-exec failures happen because Terraform attempts to connect before the operating system is fully ready.
Production-Ready remote-exec Example
resource "aws_instance" "web_server" {
ami = var.ami_id
instance_type = "t3.micro"
key_name = var.key_name
vpc_security_group_ids = [aws_security_group.ssh_access.id]
subnet_id = aws_subnet.public.id
connection {
type = "ssh"
user = "ubuntu"
private_key = file(var.private_key_path)
host = self.public_ip
timeout = "5m"
}
provisioner "remote-exec" {
inline = [
"cloud-init status --wait",
"sudo apt-get update -y",
"sudo apt-get install -y nginx",
"sudo systemctl enable nginx",
"sudo systemctl start nginx",
"sudo systemctl status nginx --no-pager"
]
}
}
This is better because it waits for cloud-init before installing packages.
Better Production Alternative: user_data and cloud-init
For VM bootstrapping, prefer cloud-init or cloud provider metadata scripts over remote-exec.
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
user_data = <<-EOF
#!/bin/bash
set -e
apt-get update -y
apt-get install -y nginx
systemctl enable nginx
systemctl start nginx
EOF
}
Why this is better:
- No SSH connection required from Terraform.
- Runs naturally during instance boot.
- Works better with autoscaling.
- Reduces security exposure.
- Improves repeatability.
Best Production Alternative: Immutable Infrastructure
The strongest production pattern is immutable infrastructure.
Immutable Infrastructure Flow
Packer Builds Golden AMI
│
▼
AMI Contains Required Packages
│
▼
Terraform Deploys AMI
│
▼
No Runtime Shell Provisioning
│
▼
Application Deployed via CI/CD or Kubernetes
In this approach, Terraform does not install packages during deployment. It simply deploys prebuilt images.
Decision Matrix: Provisioners vs Alternatives
| Requirement | Best Option |
|---|---|
| Install packages on first boot | cloud-init / user_data |
| Build reusable server image | Packer |
| Configure many servers repeatedly | Ansible / Chef / Puppet |
| Deploy apps to Kubernetes | Helm / ArgoCD / FluxCD |
| Call local API after resource creation | local-exec only if no provider exists |
| Run one-time emergency bootstrap | remote-exec with caution |
Internal Links for Better SEO and Learning Flow
Terraform Architecture and Workflow
Understand where provisioners fit inside Terraform Core, providers, and execution lifecycle.
Resources and Dependencies
Learn how Terraform orders resources before provisioners run.
Terraform State
Understand tainted resources, state tracking, and drift issues.
Troubleshooting Terraform
Debug SSH failures, tainted resources, and failed provisioners.