Provisioners and When to Avoid Them
Provisioners — local-exec, remote-exec, and file — run scripts as part of a resource's creation or destruction. They exist, they work, and the official HashiCorp guidance, which this course endorses without hedging, is that they are a last resort. They run only at create or destroy time, aren't tracked in state, break the declarative model, and turn configuration into an untracked side effect.
The reason this matters is drift. Every other resource in this chapter is reconciled — Terraform notices when reality diverges and plans the fix. A provisioner runs once, at create, and is then invisible: if its script fails halfway, or the thing it configured drifts, Terraform neither knows nor cares. The lesson here is the few legitimate uses and the better tools for everything else.
What Provisioners Do
There are three. local-exec runs a command on the machine running Terraform — useful for a local notification or writing an inventory file. remote-exec connects to the resource (over SSH or WinRM) and runs commands on it. file copies a file to the resource. Each is tied to a resource and fires at create time by default, or at destroy time with when = destroy.
Why They're a Last Resort
Four properties make provisioners brittle. They're not recorded in state, so Terraform can't tell whether one succeeded or whether its effect still holds. They run once at create, so a fix to the script doesn't re-run on existing resources. They detect no drift, so configuration that rots is never noticed. And their failure handling is awkward — a failed create-time provisioner taints the resource, and a failed destroy-time provisioner can block the destroy entirely, leaving you stuck.
Provisioner — runs a script imperatively at create or destroy time with no state tracking and no drift detection. Reach for it only when neither alternative below can do the job.
cloud-init / user-data — the instance configures itself at boot from a declarative spec you pass in. Use it for boot-time setup that should be repeatable on every launch, including ASG scale-outs.
Configuration management (Ansible) — owns ongoing configuration with its own model and can re-converge a host that drifts. Use it for anything that must be maintained over time, not just set once.
The Better Alternatives
Almost every reach for a provisioner is really a request for one of three tools. For boot-time configuration, use cloud-init through the instance's user-data — declarative, repeatable, and it runs on every ASG launch, not just the one Terraform created. For a fully-configured image, bake it with Packer so there's no boot-time work at all and instances launch in seconds. For ongoing configuration, hand off to Ansible through Terraform's outputs or a generated inventory. Provisioners replicate all three badly and track none of them.
Legitimate Uses
A handful of cases genuinely fit. A local-exec firing a one-off notification or writing a file for another local tool. Bootstrapping something that has no Terraform provider or API at all. A destroy-time cleanup that has to run before a resource goes away — deregistering a node, draining a queue. When you do use one, keep it minimal, make it idempotent so a re-run is safe, and comment why no declarative alternative fit.
resource "aws_db_instance" "app" { # ... database arguments ... provisioner "local-exec" { # one-off notify; no provider exists for this internal webhook command = "./notify.sh 'db ${self.identifier} provisioned'" } }
This is defensible because it does one small thing, has no declarative equivalent (an internal webhook with no provider), and references self rather than reaching across resources. What it is not is a place to install Postgres or configure the OS — that would be configuration management smuggled in as a side effect.
null_resource, terraform_data, and Triggers
Sometimes you want a provisioner not attached to any real resource. terraform_data (the modern replacement for null_resource) is a resource that exists only to host one, with a triggers_replace argument that re-runs it when a watched value changes. It's the right tool for a genuinely standalone action — but the moment you find yourself chaining several into an imperative pipeline, that pipeline belongs in CI or a configuration tool, not in Terraform pretending to be a shell script.
resource "terraform_data" "seed" { # re-run only when the schema version changes triggers_replace = [var.schema_version] provisioner "local-exec" { command = "./seed-db.sh" } }
triggers_replace is what gives this any repeatability — without it the provisioner runs once and never again. Even so, a database seed is borderline; if it grows beyond one idempotent script, it has outgrown Terraform.
- Using
remote-execto install and configure software, recreating configuration management badly with no drift tracking and no re-convergence. - Depending on a provisioner for critical setup, then having it silently not re-run because the resource was never recreated — the new instance comes up unconfigured.
- Writing a destroy-time provisioner that fails and blocks the destroy entirely, leaving you unable to tear the resource down.
- Chaining
terraform_dataornull_resourceprovisioners into an imperative pipeline that belongs in CI or a real configuration tool. - Adding a
local-execwithouttriggers_replaceonterraform_dataand expecting it to re-run on change — it runs once and is then inert.
- Treat provisioners as a last resort; reach for cloud-init, a baked Packer image, or Ansible first.
- Use cloud-init/user-data for boot-time configuration so it's declarative and re-runs on every ASG launch, not just the first.
- Hand ongoing configuration to a configuration-management tool via Terraform outputs or a generated inventory.
- If you must use a provisioner, keep it minimal and idempotent, and comment exactly why no declarative alternative fit.
- Use
terraform_datawithtriggers_replaceovernull_resourcefor a standalone action, and stop before it becomes a pipeline.
Knowledge Check
Why are provisioners considered a last resort?
- They aren't tracked in state, run once at create, detect no drift, and have awkward failure handling
- They run slower than ordinary resources and add an extra per-apply cost to every run
- They only work on Linux instances reachable over SSH, never on Windows hosts or the local Terraform host
- They require a separate provider plugin that HashiCorp has deprecated and no longer maintains
What is the right tool for boot-time instance configuration that should re-run on every ASG launch?
- cloud-init through the instance's user-data — declarative and applied on every launch
- A
remote-execprovisioner attached to the launch template that SSHes in to configure each host - A
fileprovisioner copying a setup script at create time - A destroy-time
local-execthat reconfigures the next instance
Which is a defensible use of a provisioner?
- A minimal, idempotent
local-execnotification for something with no Terraform provider - A
remote-execthat installs and configures the application stack on each instance - A chain of
null_resourceprovisioners wired together to form a full deployment pipeline - A provisioner that manages and re-converges ongoing OS package versions over the host's lifetime
What does triggers_replace on a terraform_data resource give its provisioner?
- A way to re-run when a watched value changes, instead of running only once at create
- State tracking that records whether the provisioner's effect on the host still holds
- Automatic drift detection on whatever the script configured on the target host
- The ability to run the provisioner on a real AWS resource remotely
You got correct