Why Provisioners Are a Last Resort

Scenario: Terraform successfully creates a new server, then a 200-line remote-exec script meant to configure it partially runs and fails halfway through - and Terraform has no real way to describe, or recover from, exactly what state that left the machine in.

Everything you've learned so far about Terraform works because it can precisely model a resource: its schema, its exact current attributes, whether it's fully created, and what changing one field will do. A provisioner is Terraform's escape hatch for running arbitrary imperative commands - shell scripts, file copies - typically right after a resource is created or right before it's destroyed. The trouble is that a provisioner's actual content is a completely opaque black box to Terraform: it has no way to know whether the script is idempotent (safe to re-run without side effects), no way to know what its individual steps actually did, and no way to represent its result as a normal managed attribute the way it does for every provider resource you've worked with all track.

This has real consequences: if a provisioner fails partway through, Terraform typically marks the whole resource as "tainted" (needing replacement), but a naive retry of a non-idempotent script can now duplicate whatever side effects the first partial run already caused (sending a notification twice, appending to a file twice). Because of all this, the strong default recommendation - and the reason this section exists at all - is to prefer purpose-built alternatives wherever one exists: a provider resource designed for the job, a prebuilt machine image with the software already baked in, cloud-init/user-data for first-boot bootstrap, or a real configuration-management tool. Provisioners exist for the narrow remainder of cases where none of those actually fit.

Analogy: Terraform can precisely track that a sealed, labeled package was delivered to a specific address - that's exactly what a normal resource is. It has no way to know whether every individual instruction scrawled on an unstructured handwritten note tucked inside that package was actually carried out correctly - that opaque note is what a provisioner script looks like to Terraform.

A worked configuration

resource "fakecloud_server" "api" {
  name = "api"
  # Prefer image_id/user_data when the provider supports them.
}

Provisioners also entangle something that's supposed to be a pure infrastructure decision (should this object exist?) with a pile of runtime concerns that have nothing to do with the resource's own lifecycle: is the network path to the new server actually reachable yet, are the SSH credentials valid and available, is some external package mirror online right now. During the single riskiest moment of a resource's life - the moment right after creation - a provisioner effectively turns what should be a clean, deterministic graph operation into an ad hoc distributed shell script that can fail for reasons entirely unrelated to infrastructure itself.

Common mistakes in production

Goal: A successful provisioner run is not recorded anywhere as its own separately managed, inspectable object the way a normal resource attribute is. That means a future terraform plan may never notice if the configuration a provisioner set up inside a machine has since drifted - Terraform has no schema for "is this script's effect still true," so it simply can't check. Reinforce this lesson in the tf-terraform-data-triggers Terraform lab. Open /labs/terraform and choose slug tf-terraform-data-triggers; the lab runs real Terraform against the offline FakeCloud provider.