Terraform Provisioners
Learn what Terraform provisioners are, how local-exec, remote-exec, file, connection blocks, and null_resource work, and why provisioners should be a last resort.
What Are Terraform Provisioners?
Provisioners are Terraform features that let you run scripts or copy files as part of resource creation or destruction. They sit at the edge between infrastructure provisioning and machine configuration.
In plain English, provisioners answer requests like:
- “After the EC2 instance is created, run these commands on it.”
- “Copy this file to the new VM over SSH.”
- “Run a local shell command after Terraform finishes creating a resource.”
Terraform introduced provisioners in its early 0.x releases, long before Terraform 1.0. They have been part of the tool for years, which is why you still see them in older blog posts, legacy codebases, and interview questions.
But there is a crucial catch: HashiCorp recommends against relying on provisioners for normal infrastructure design. They still exist, and they are sometimes useful, but they are intentionally treated as a last resort.
That tension is what makes this topic important. You need to understand both how provisioners work and why experienced Terraform users try to avoid them.
Why Provisioners Feel Attractive at First
Provisioners are appealing because they look like a shortcut.
Imagine you create an EC2 instance and then want to:
- install Nginx
- write a config file
- restart a service
- call a registration API
A provisioner can do all of that. That sounds convenient, especially if you come from a Bash or configuration-management background.
Here is the beginner mental model:
- create resource
- run commands
- machine is ready
That model is not wrong, but it hides some operational problems. Provisioners can fail halfway through. They can depend on SSH availability. They can make repeated applies unpredictable. They can blur the separation between “Terraform manages infrastructure state” and “something else manages software configuration.”
So before you treat provisioners as the default answer, learn their mechanics and their tradeoffs.
The Three Common Provisioners
Terraform's most common provisioners are:
local-execremote-execfile
Each one does something slightly different.
local-exec: Run Commands on the Machine Running Terraform
local-exec executes a command on the local machine where Terraform is running. That might be:
- your laptop
- a CI runner
- a build container
- an automation host
Example:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
provisioner "local-exec" {
command = "echo ${self.public_ip} >> inventory.txt"
}
}
What happens here?
After Terraform creates the EC2 instance, it runs the local shell command and appends the instance public IP to inventory.txt on the machine running Terraform.
Common uses of local-exec
- writing an inventory file for another tool
- calling an external API
- triggering a follow-up script
- sending a notification
- running a one-off local registration command
Important limitation
local-exec does not run on the created resource. It runs locally. That distinction matters a lot.
If Terraform runs inside GitLab CI, the command runs in the CI job container or runner environment, not on your EC2 instance.
remote-exec: Run Commands on the Provisioned Resource
remote-exec runs commands on the target machine itself, usually over SSH for Linux hosts or WinRM for Windows.
Example:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
key_name = aws_key_pair.deployer.key_name
provisioner "remote-exec" {
inline = [
"sudo apt-get update -y",
"sudo apt-get install -y nginx",
"sudo systemctl enable nginx",
"sudo systemctl restart nginx"
]
}
connection {
type = "ssh"
host = self.public_ip
user = "ubuntu"
private_key = file(var.private_key_path)
}
}
This is one of the most common provisioner examples you will see.
Why it feels useful
It lets Terraform create the VM and then configure software on it immediately. That can make a tutorial seem smooth because one terraform apply produces a working server.
Why it is fragile
This setup assumes all of the following are true at the exact right time:
- the instance is booted and reachable
- the security group allows SSH
- the route table is correct
- the private key matches
- the correct SSH username is used
- cloud-init has finished enough for login to work
If any one of those assumptions breaks, the apply fails even though the infrastructure resource itself may already exist.
file: Copy Files to a Remote Resource
The file provisioner copies a file or directory from the Terraform runner to the remote resource.
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
key_name = aws_key_pair.deployer.key_name
provisioner "file" {
source = "app.conf"
destination = "/tmp/app.conf"
}
connection {
type = "ssh"
host = self.public_ip
user = "ubuntu"
private_key = file(var.private_key_path)
}
}
You often see file combined with remote-exec:
- copy a file
- move it into place
- restart the service
Example:
provisioner "remote-exec" {
inline = [
"sudo mv /tmp/app.conf /etc/myapp/app.conf",
"sudo systemctl restart myapp"
]
}
Again, it works, but it turns Terraform into a machine bootstrap-and-configure tool, which is not its strongest role.
The connection Block
Both remote-exec and file usually need a connection block. This tells Terraform how to reach the remote host.
connection {
type = "ssh"
host = self.public_ip
user = "ubuntu"
private_key = file(var.private_key_path)
}
Key fields
type, the connection method, commonlysshhost, the destination addressuser, the login usernameprivate_key, the SSH private key contents
Other useful fields can include:
porttimeoutagentpasswordbastion_host
Why self appears here
Inside a provisioner or connection block, self refers to the resource being provisioned. So self.public_ip means “use the public IP of this resource.”
That is convenient, but it also shows how tightly provisioners depend on resource timing and runtime connectivity.
Provisioners Run on Create or Destroy
Provisioners normally run when a resource is created. Some can also run at destroy time.
Example destroy provisioner:
resource "null_resource" "cleanup" {
provisioner "local-exec" {
when = destroy
command = "echo Cleaning up external registration"
}
}
Destroy-time provisioners are even trickier than create-time ones because they fire during teardown, when resources may already be disappearing.
If the destroy-time cleanup is truly critical, you should think carefully about whether Terraform is the right place to model it.
Why HashiCorp Recommends Against Provisioners
This is the most important section of the lesson.
HashiCorp recommends avoiding provisioners where possible because they are often:
- fragile
- hard to reason about
- not fully idempotent
- hard to test reliably
- outside Terraform's core plan/apply state model
Let us unpack that.
1. They are fragile
Provisioners depend on live connectivity, timing, credentials, and runtime behavior. Infrastructure might be created successfully while the provisioner still fails. That leaves you with partial success and messy recovery.
2. They are not truly declarative
Terraform is strongest when you describe the desired end state. Provisioners push you back toward procedural steps: run this command, then this file copy, then restart that service.
3. They weaken idempotence
Idempotence means you can run the same operation repeatedly and converge on the same result. Provisioners often make that harder, especially if scripts append, mutate, or depend on current machine state.
4. They blur responsibilities
Terraform is an infrastructure provisioning tool. Configuration management tools such as Ansible are built specifically for software installation, service management, and repeated host configuration.
5. They break the clean plan/apply model
Terraform can show you the plan for resource changes. It cannot give you a rich preview of the side effects inside your shell commands. That reduces predictability.
Better Alternatives to Provisioners
HashiCorp does not say “never automate post-provision tasks.” It says “use better-native patterns first.”
1. user_data or cloud-init
For VM bootstrapping, user_data and cloud-init are usually cleaner than remote-exec.
Why?
- they are designed for first boot
- they do not require Terraform to SSH into the machine
- the instance can bootstrap itself
- they fit cloud-native workflows better
2. Packer
If the same packages and files should exist on every VM, bake them into a machine image with Packer instead of installing them during every Terraform apply.
That gives you:
- faster instance launches
- more predictable boot behavior
- versioned machine images
3. Ansible
If you need rich configuration management, Ansible is usually a better tool than Terraform provisioners. It is built for:
- package installation
- service configuration
- file templates
- idempotent repeatable host changes
- fleet-wide configuration updates
4. External application deployment pipelines
If the post-provision step is “deploy the app,” that step often belongs in CI/CD, not inside Terraform.
When Provisioners Are Acceptable
Provisioners are not forbidden. They are a last resort.
Good last-resort examples include:
- registering a resource in an external system that has no Terraform provider
- performing a one-time bootstrap step that cannot be modeled another way
- running a local notification or metadata capture after apply
- temporary bridging work during migration away from legacy scripts
The key question is:
Can this be modeled in a more declarative, provider-native, or pipeline-friendly way?
If yes, choose that instead.
If no, a small carefully scoped provisioner may be acceptable.
null_resource and Triggers
Provisioners often appear together with null_resource.
A null_resource does not create real infrastructure. It is just a Terraform-managed placeholder that can run provisioners.
Example:
resource "null_resource" "build_assets" {
triggers = {
app_version = var.app_version
}
provisioner "local-exec" {
command = "echo Building assets for ${var.app_version}"
}
}
What are triggers?
triggers is a map of values Terraform watches. If one of those values changes, Terraform replaces the null_resource, which causes the provisioner to run again.
That makes null_resource useful for:
- simple orchestration glue
- one-off local tasks tied to input changes
- legacy integrations during migration
But it also makes it easy to abuse Terraform as a generic task runner.
Example: rerun when a file hash changes
resource "null_resource" "upload_config" {
triggers = {
config_sha = filesha256("app.conf")
}
provisioner "local-exec" {
command = "echo Config file changed"
}
}
This can be handy, but again, ask whether the task belongs in Terraform at all.
A Realistic Decision Framework
When you feel tempted to use a provisioner, pause and ask:
- Is this infrastructure creation or machine configuration?
- Can a provider resource model this directly?
- Can
user_data, cloud-init, or a startup script handle it more reliably? - Would Packer produce a cleaner image-based solution?
- Would Ansible or CI/CD own this concern better?
- Is this a one-off edge case with no good provider support?
If the answer reaches question 6, a provisioner may be justified.
Example: A Minimal Acceptable local-exec
Here is a small example that is often easier to defend than remote host configuration:
resource "aws_eip" "web" {
domain = "vpc"
provisioner "local-exec" {
command = "echo ${self.public_ip}"
}
}
This just prints or captures information locally after a resource is created. It does not turn Terraform into a configuration engine.
Example: A Provisioner Pattern to Avoid
This style is much harder to justify:
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
provisioner "file" {
source = "./entire-app-directory"
destination = "/tmp/app"
}
provisioner "remote-exec" {
inline = [
"sudo apt-get update -y",
"sudo apt-get install -y docker.io",
"sudo mv /tmp/app /opt/app",
"sudo docker compose up -d"
]
}
}
Why is this weak?
- it depends on SSH timing
- it mixes app deployment into infra apply
- it is hard to re-run safely
- it becomes painful in CI/CD
- it is difficult to preview in a plan
That workload is usually better handled by image baking, startup automation, or a deployment pipeline.
Final Guidance
Provisioners are part of Terraform, so you should know them. But mature Terraform practice is not about knowing every way to run shell commands. It is about knowing when not to do that.
The best summary is this:
local-execruns on the Terraform runnerremote-execruns commands on the created machinefilecopies files to a remote machineconnectiontells Terraform how to reach that machinenull_resourcecan act as a triggerable placeholder for provisioners- all of them are best treated as exceptions, not your default design pattern
If you keep that mental model, you will be able to read legacy Terraform code confidently without turning new Terraform projects into shell-script orchestration systems.
Practice Questions
Question 1: local-exec Scope
Where does a `local-exec` provisioner run?
Question 2: Why Avoid Provisioners
What is the main reason HashiCorp recommends avoiding provisioners when possible?
Question 3: null_resource Triggers
What do `triggers` do on a `null_resource`?