Provisioners
Provisioners let Terraform run scripts or commands as part of creating or destroying a resource — copying files, bootstrapping software, or invoking a local tool. They exist for the rare cases where declarative configuration cannot express what you need, and HashiCorp explicitly treats them as a last resort. Because they run imperative side effects that Terraform cannot model in state, they break the predictability that makes infrastructure as code reliable. Reach for cloud-native bootstrapping first; use provisioners only when nothing else fits.
Why provisioners are a last resort
Terraform’s model is declarative: it knows the desired state, compares it to the current state, and reconciles the difference. A provisioner is an opaque imperative step. Terraform cannot see what the script did, cannot detect drift it introduced, and cannot reverse it on a later change. If a provisioner fails partway through, the resource is left tainted and must be recreated.
Prefer these alternatives before adding a provisioner:
| Goal | Preferred approach |
|---|---|
| Bootstrap a Linux VM at boot | user_data / cloud-init |
| Install and configure software | Ansible, Chef, Puppet, or a baked AMI (Packer) |
| Pass data into an instance | Instance metadata, SSM Parameter Store, tags |
| React to resource creation | A separate CI step or null_resource with explicit triggers |
Warning: Provisioners run only on
apply, not onplan. Their effects are invisible to Terraform’s diff, so they are a frequent source of “works once, breaks on the next apply” surprises. Treat every provisioner as technical debt.
The connection block
remote-exec and file provisioners need to reach the target machine over SSH or WinRM. The connection block tells Terraform how to authenticate. It can be declared inside a provisioner or once at the resource level to be shared.
resource "aws_instance" "web" {
ami = "ami-0c1234567890abcde"
instance_type = "t3.micro"
key_name = aws_key_pair.deployer.key_name
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/deployer.pem")
host = self.public_ip
}
}
self refers to the resource the provisioner is attached to, so self.public_ip resolves to the instance’s address. Common connection arguments:
| Argument | Purpose |
|---|---|
type | ssh (default) or winrm |
host | Target address, usually self.public_ip |
user | Login user |
private_key / password | Credentials |
bastion_host | Jump host for private instances |
timeout | How long to wait for connectivity (default 5m) |
remote-exec
remote-exec runs commands on the remote machine after it is reachable. Use inline for a short list of commands or script / scripts to upload and run files.
resource "aws_instance" "web" {
ami = "ami-0c1234567890abcde"
instance_type = "t3.micro"
key_name = aws_key_pair.deployer.key_name
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/deployer.pem")
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"sudo apt-get update -y",
"sudo apt-get install -y nginx",
"sudo systemctl enable --now nginx",
]
}
}
Output:
aws_instance.web: Provisioning with 'remote-exec'...
aws_instance.web (remote-exec): Connecting to remote host via SSH...
aws_instance.web (remote-exec): Host: 203.0.113.42
aws_instance.web (remote-exec): User: ubuntu
aws_instance.web (remote-exec): Connected!
aws_instance.web (remote-exec): Setting up nginx (1.24.0-2ubuntu7) ...
aws_instance.web: Creation complete after 1m12s [id=i-0abc123def4567890]
file
The file provisioner copies a local file or directory to the remote machine. It uses the same connection block.
provisioner "file" {
source = "config/nginx.conf"
destination = "/tmp/nginx.conf"
}
You can also push inline content with content instead of source, which is handy for rendering a templatefile() result directly onto the host.
local-exec
local-exec runs a command on the machine running Terraform — not on the resource. It needs no connection block and is often used to invoke external tooling or record outputs.
resource "aws_instance" "web" {
ami = "ami-0c1234567890abcde"
instance_type = "t3.micro"
provisioner "local-exec" {
command = "ansible-playbook -i '${self.public_ip},' site.yml"
working_dir = "${path.module}/ansible"
environment = {
ANSIBLE_HOST_KEY_CHECKING = "False"
}
}
}
On Windows you can switch shells with interpreter, e.g. interpreter = ["PowerShell", "-Command"].
Creation vs destroy provisioners
By default a provisioner runs at creation time. Set when = destroy to run it just before the resource is destroyed — useful for graceful drain or deregistration.
provisioner "local-exec" {
when = destroy
command = "echo 'Draining ${self.id}' && ./deregister.sh ${self.id}"
}
Destroy-time provisioners may only reference self, count.index, and each.key — not other variables or resources — to keep the destroy graph self-contained.
Failure behavior
If a provisioner fails, Terraform marks the resource tainted, meaning the next apply destroys and recreates it. You can change this with on_failure.
on_failure value | Behavior |
|---|---|
fail (default) | Error out and taint the resource |
continue | Ignore the error and proceed |
provisioner "remote-exec" {
on_failure = continue
inline = ["./optional-warmup.sh"]
}
Everything here works identically under OpenTofu, which shares the same HCL2 syntax and provisioner implementations.
Best Practices
- Exhaust declarative alternatives —
user_data, cloud-init, baked images, SSM — before adding a provisioner. - Keep connection credentials out of code; pull keys from a secrets manager or variables, never commit
.pemfiles. - Make provisioner scripts idempotent so reruns after recreation are safe.
- Pair
null_resourcewithtriggerswhen you need a provisioner that reruns on input changes rather than on resource lifecycle. - Use
on_failure = continuesparingly — silent failures hide real problems. - Prefer a dedicated configuration management or CI step over destroy-time provisioners, which are fragile and run with limited context.