Provisioners
Provisioners are one of Terraform’s escape hatches: they let Terraform run commands when a resource is created or destroyed. That sounds useful, but it also reveals the trade-off immediately. Instead of describing infrastructure and letting a provider reconcile it, you’re asking Terraform to perform an imperative side effect.
That makes provisioners worth understanding, but rarely worth reaching for first.
What Are Provisioners?
Terraform providers are normally responsible for creating and managing resources. A resource declaration describes the desired object, and the provider translates that declaration into API operations.
A provisioner works differently. It runs a command as part of a resource lifecycle.
Terraform has two built-in provisioners you’ll encounter most often:
local-execruns a command on the machine executing Terraform.remote-execconnects to a remote machine and runs commands there.
A simple local-exec looks like this:
terraform {
required_version = ">= 1.5.0"
}
resource "terraform_data" "example" {
provisioner "local-exec" {
command = "echo 'Terraform created the resource'"
}
}The terraform_data resource gives us something to attach the provisioner to without introducing a cloud resource.
Run:
terraform init
terraform applyAfter Terraform creates the terraform_data resource, the provisioner runs on the machine where Terraform is executing.
The important mental model is:
Terraform
│
├── asks provider to manage a resource
│
└── runs provisioner as an imperative side effectThe second path is where most of the operational complications begin.
Why Were Provisioners Introduced?
Terraform cannot model every action that might need to happen around infrastructure.
For example, an unusual system might require:
- running a command after creating a resource
- notifying an external system
- performing a one-off local action
- invoking a legacy mechanism that has no Terraform resource
Provisioners provide an escape hatch for cases like these.
They are not intended to replace resources, application deployment systems, configuration management, or image-building workflows.
local-exec And remote-exec
local-exec
local-exec executes on the machine running Terraform.
For example:
resource "terraform_data" "backup_notice" {
provisioner "local-exec" {
command = "echo 'Infrastructure change completed'"
}
}That command does not run on the infrastructure being managed. If Terraform runs in CI, the command runs in the CI environment. If Terraform runs from your laptop, it runs on your laptop.
That distinction matters.
A command such as:
command = "touch /tmp/terraform-finished"creates a file on the Terraform execution machine, not on an EC2 instance.
remote-exec
remote-exec connects to a remote machine and executes commands there.
A simplified example requires an address and connection details:
resource "terraform_data" "remote_setup" {
provisioner "remote-exec" {
inline = [
"echo 'Connected successfully'",
"uname -a"
]
connection {
type = "ssh"
host = var.host
user = var.user
private_key = file(var.private_key)
}
}
}The example is intentionally about the mechanism rather than a complete server deployment. It assumes that an SSH-accessible machine already exists.
You would need variables such as:
variable "host" {
type = string
}
variable "user" {
type = string
}
variable "private_key" {
type = string
sensitive = true
}The connection configuration tells Terraform how to reach the remote system. For SSH, this commonly includes:
- connection type
- hostname or IP address
- username
- authentication material
- optionally a port or other connection settings
This creates an important dependency that ordinary Terraform resources often avoid: Terraform now needs network connectivity to the target at exactly the right point in the lifecycle.
Creation-Time And Destroy-Time Provisioners
A provisioner normally runs during resource creation.
You can also attach one to destruction with:
resource "terraform_data" "example" {
provisioner "local-exec" {
when = destroy
command = "echo 'Terraform is destroying the resource'"
}
}The when = destroy setting changes the lifecycle point at which the provisioner runs.
A destroy-time provisioner is particularly important to treat cautiously. Destruction can happen in situations where the environment is already partially unavailable. A command depending on another service, credential, network connection, or external system may therefore fail when you least want it to.
Warning
A destroy-time provisioner is not a guaranteed external transaction. Treat it as a best-effort lifecycle action, not as a substitute for reliable cleanup architecture.
What Happens When A Provisioner Fails?
Provisioners are part of the resource operation, so failure matters to Terraform.
Consider:
resource "terraform_data" "failure" {
provisioner "local-exec" {
command = "sh -c 'echo failing; exit 1'"
}
}Run:
terraform init
terraform applyThe command exits with status 1, so Terraform reports a failure.
The important point is that Terraform does not magically undo every external side effect caused before the failure. The command might already have created files, sent notifications, modified another system, or performed some other action.
That is one reason imperative side effects are harder to reason about than provider-managed resources.
Why Provisioners Are A Last Resort
Terraform’s strength comes from describing desired infrastructure and allowing providers to reconcile that description.
Suppose you want a server to contain a particular package. One approach is:
Terraform
│
└── create server
│
└── SSH into server
│
└── install packageThis mixes infrastructure provisioning with imperative configuration.
A more robust architecture usually makes the desired server state part of another mechanism:
Terraform
│
└── create server
│
├── image already contains required software
├── cloud-init/user-data performs first-boot setup
└── configuration management manages ongoing configurationThe exact alternative depends on the system, but the principle is consistent: model the desired state in the system responsible for that kind of state.
Provisioners introduce several problems.
Ordering And Connectivity
remote-exec needs a machine that is not merely created, but reachable.
A cloud instance can exist while its operating system is still booting. DNS might not have propagated. SSH might not be ready. Security rules might not permit access yet.
Terraform now has to deal with another timing-sensitive system.
Reproducibility
A command such as:
apt-get install somethingdepends on the state of the operating system, package repositories, network access, and the command’s behavior at execution time.
Two runs can therefore encounter different conditions even when the Terraform configuration has not changed.
Declarative Versus Imperative Behavior
A resource declaration says what Terraform should manage.
A command says what Terraform should execute.
Terraform can compare resource configuration with state and provider information. It has much less semantic understanding of an arbitrary shell command.
For example:
provisioner "local-exec" {
command = "curl https://example.com/deploy"
}Terraform cannot reason about the complete meaning of that external operation. It only knows that a command exists.
Predictability
Provisioners can make plans and applies harder to reason about because the important effects may live outside Terraform’s resource graph and state model.
If the command changes an external system, Terraform may not know that the external system changed.
Note
A provisioner is not automatically “bad.” The problem is using imperative side effects where Terraform or another purpose-built mechanism could model the desired result more reliably.
When A Provisioner Can Be Reasonable
There are legitimate escape-hatch situations.
For example, a small local notification after a successful infrastructure operation might be acceptable:
resource "terraform_data" "notification" {
provisioner "local-exec" {
command = "echo 'Terraform operation completed'"
}
}Other reasonable cases can include:
- interacting with a legacy system that has no usable provider
- a one-off local integration
- a narrowly scoped bootstrap action where no better mechanism exists
- transitional infrastructure while replacing an older process
The key question is not “Can Terraform run this command?”
It is:
“Is an imperative side effect really the best representation of this requirement?”
If a Terraform resource, data source, image, cloud-init/user-data mechanism, configuration-management system, or external service can model the requirement more reliably, prefer that mechanism.
A Failure Scenario
Suppose you use:
resource "terraform_data" "deploy" {
provisioner "local-exec" {
command = "sh -c 'echo deploying; exit 1'"
}
}Terraform can report that the provisioner failed, but Terraform cannot infer the meaning of everything the command may have done before exiting.
A command might have:
- created a remote object
- modified a file
- sent a notification
- failed halfway through
- left an external system in a partially changed state
Terraform’s state cannot automatically reconstruct all of those effects.
This is the fundamental limitation to remember:
Provider-managed resource
↓
Terraform understands a resource lifecycle
Provisioner
↓
Terraform executes a command
↓
The command's side effects are mostly opaqueThat is why provisioners belong near the edge of a production Terraform design rather than at its center.