Chapter 8: Managing Real Infrastructure
Topic 51

Provisioners and When to Avoid Them

Provisioners

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.

Three ways to configure a host
Provisioner
Imperative script at create or destroy time. Not in state, no drift detection, runs once. A last resort.
cloud-init / user-data
Declarative boot config passed to the instance. Repeatable on every launch, including ASG scale-outs.
Config management
Ansible and the like own ongoing configuration and re-converge a host that drifts — maintained over time, not set once.

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 vs cloud-init vs configuration management

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.

notify.tf — a legitimate, minimal local-exec
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.

data.tf — terraform_data with a re-run trigger
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.

Common Mistakes
  • Using remote-exec to 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_data or null_resource provisioners into an imperative pipeline that belongs in CI or a real configuration tool.
  • Adding a local-exec without triggers_replace on terraform_data and expecting it to re-run on change — it runs once and is then inert.
Best Practices
  • 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_data with triggers_replace over null_resource for a standalone action, and stop before it becomes a pipeline.
Comparable tools Ansible and cloud-init are the proper configuration tools Packer bakes images to avoid boot-time configuration entirely CloudFormation has cfn-init and user-data for the same role

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-exec provisioner attached to the launch template that SSHes in to configure each host
  • A file provisioner copying a setup script at create time
  • A destroy-time local-exec that reconfigures the next instance

Which is a defensible use of a provisioner?

  • A minimal, idempotent local-exec notification for something with no Terraform provider
  • A remote-exec that installs and configures the application stack on each instance
  • A chain of null_resource provisioners 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