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:
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.
Provisioners are appealing because they look like a shortcut.
Imagine you create an EC2 instance and then want to:
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:
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.
Terraform's most common provisioners are:
local-execremote-execfileEach one does something slightly different.
local-exec: Run Commands on the Machine Running Terraformlocal-exec executes a command on the local machine where Terraform is running. That might be:
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"
}
}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.
local-execlocal-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 Resourceremote-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.
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.
This setup assumes all of the following are true at the exact right time:
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 ResourceThe 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:
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.
connection BlockBoth 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)
}type, the connection method, commonly sshhost, the destination addressuser, the login usernameprivate_key, the SSH private key contentsOther useful fields can include:
porttimeoutagentpasswordbastion_hostself appears hereInside 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 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.
This is the most important section of the lesson.
HashiCorp recommends avoiding provisioners where possible because they are often:
Let us unpack that.
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.
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.
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.
Terraform is an infrastructure provisioning tool. Configuration management tools such as Ansible are built specifically for software installation, service management, and repeated host configuration.
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.
HashiCorp does not say ânever automate post-provision tasks.â It says âuse better-native patterns first.â
user_data or cloud-initFor VM bootstrapping, user_data and cloud-init are usually cleaner than remote-exec.
Why?
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:
If you need rich configuration management, Ansible is usually a better tool than Terraform provisioners. It is built for:
If the post-provision step is âdeploy the app,â that step often belongs in CI/CD, not inside Terraform.
Provisioners are not forbidden. They are a last resort.
Good last-resort examples include:
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 TriggersProvisioners 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}"
}
}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:
But it also makes it easy to abuse Terraform as a generic task runner.
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.
When you feel tempted to use a provisioner, pause and ask:
user_data, cloud-init, or a startup script handle it more reliably?If the answer reaches question 6, a provisioner may be justified.
local-execHere 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.
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?
That workload is usually better handled by image baking, startup automation, or a deployment pipeline.
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-exec runs on the Terraform runnerremote-exec runs commands on the created machinefile copies files to a remote machineconnection tells Terraform how to reach that machinenull_resource can act as a triggerable placeholder for provisionersIf 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.
<MCQExercise
exercise={{
id: 'terraform-provisioners-q1',
title: 'Question 1: local-exec Scope',
description: 'Where does a local-exec provisioner run?',
options: [
{ id: 'a', text: 'On the cloud resource that Terraform just created' },
{ id: 'b', text: 'Inside the Terraform state file' },
{ id: 'c', text: 'On the machine or runner where Terraform itself is executing' },
{ id: 'd', text: 'Only inside Terraform Cloud and nowhere else' },
],
correctId: 'c',
explanation: 'local-exec runs on the local execution environment for Terraform. That may be your laptop or a CI runner, but it is not the remote cloud resource.',
wrongExplanations: {
a: 'That behavior describes remote-exec, not local-exec.',
b: 'Terraform state stores data, not shell execution environments.',
d: 'local-exec is not limited to Terraform Cloud.',
},
}}
/>
<MCQExercise exercise={{ id: 'terraform-provisioners-q2', title: 'Question 2: Why Avoid Provisioners', description: 'What is the main reason HashiCorp recommends avoiding provisioners when possible?', options: [ { id: 'a', text: 'Provisioners are deprecated and removed from all modern Terraform versions' }, { id: 'b', text: 'Provisioners are often fragile and push Terraform away from its declarative, idempotent model' }, { id: 'c', text: 'Provisioners can only be used with AWS resources' }, { id: 'd', text: 'Provisioners always cost extra money in Terraform Cloud' }, ], correctId: 'b', explanation: 'Provisioners depend on runtime connectivity, scripts, and side effects that are harder to model, preview, and repeat safely. That is why they are treated as a last resort rather than a normal Terraform pattern.', wrongExplanations: { a: 'Provisioners still exist. The guidance is to avoid them when there is a better alternative.', c: 'Provisioners are not AWS-specific.', d: 'The guidance is about reliability and design, not about a mandatory extra cost.', }, }} />
<MCQExercise
exercise={{
id: 'terraform-provisioners-q3',
title: 'Question 3: null_resource Triggers',
description: 'What do triggers do on a null_resource?',
options: [
{ id: 'a', text: 'They tell Terraform which cloud provider to use for the null_resource' },
{ id: 'b', text: 'They define firewall rules needed by remote-exec provisioners' },
{ id: 'c', text: 'They store values Terraform watches so the null_resource can be replaced and re-run when those values change' },
{ id: 'd', text: 'They encrypt provisioner commands before Terraform executes them' },
],
correctId: 'c',
explanation: 'A null_resource uses triggers as change detectors. When a trigger value changes, Terraform replaces the null_resource, causing its provisioners to run again.',
wrongExplanations: {
a: 'A null_resource does not select a cloud provider this way.',
b: 'Firewall rules are unrelated to triggers.',
d: 'triggers do not encrypt anything; they are simply tracked input values.',
},
}}
/>
Because Terraform cannot reason about them. A provisioner's effects are invisible to the plan, are not recorded in state, and are not re-run when the underlying script changes, so the resource can be "up to date" while the configuration it applied is stale. They also introduce a network and credential dependency at apply time, which makes applies fail for reasons unrelated to infrastructure. Failures mark the resource tainted, forcing a destroy and recreate on the next run.
For bootstrapping a machine, use user_data or cloud-init, which the cloud provider executes and which is part of the resource's own configuration. For configuring software, use Ansible, Chef or Puppet after Terraform has created the infrastructure. For baking software into an image, use Packer so instances start ready. For calling an API a provider does not cover, prefer a dedicated provider or an external data source over local-exec and curl.
A provisioner failed, so Terraform marks the resource as unreliable. The infrastructure exists but its configuration did not complete. The next plan proposes destroying and recreating it, because Terraform has no way to re-run just the provisioner. You can clear the mark with terraform untaint if you fixed the problem by hand, though that leaves Terraform trusting a state it did not create.
Adding when = destroy runs the provisioner before the resource is destroyed, typically to deregister a node or drain connections. They come with real constraints: they cannot reference variables or most other values, only self, and they must still be present in configuration when the destroy happens, so removing the resource block and the provisioner together means the provisioner never runs. Plan the removal in two steps if a destroy-time action matters.
For genuinely local, idempotent side effects that do not represent infrastructure. Writing a kubeconfig for a subsequent step, or triggering a notification. It is pragmatic and widely used. It becomes a problem when it is doing work that should be declarative, because nothing plans it, nothing detects drift in it, and nobody reviewing the plan can see what it will do. If a local-exec is creating or modifying infrastructure, that infrastructure belongs in a resource block.
Build a Terraform pipeline in GitLab CI/CD with validate, plan, manual apply and destroy stages, GitLab-managed state and secure credential handling.
Secure Terraform code by scanning with tfsec and Checkov, protecting secrets in state, fixing common misconfigurations and adding checks to CI/CD.
Learn what Terraform is and how Infrastructure as Code works, why it beats clicking through consoles, and how it compares to Ansible and CloudFormation.