Skip to content
Infrastructure as Code iac resources 4 min read

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:

GoalPreferred approach
Bootstrap a Linux VM at bootuser_data / cloud-init
Install and configure softwareAnsible, Chef, Puppet, or a baked AMI (Packer)
Pass data into an instanceInstance metadata, SSM Parameter Store, tags
React to resource creationA separate CI step or null_resource with explicit triggers

Warning: Provisioners run only on apply, not on plan. 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:

ArgumentPurpose
typessh (default) or winrm
hostTarget address, usually self.public_ip
userLogin user
private_key / passwordCredentials
bastion_hostJump host for private instances
timeoutHow 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 valueBehavior
fail (default)Error out and taint the resource
continueIgnore 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 .pem files.
  • Make provisioner scripts idempotent so reruns after recreation are safe.
  • Pair null_resource with triggers when you need a provisioner that reruns on input changes rather than on resource lifecycle.
  • Use on_failure = continue sparingly — 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.
Last updated June 14, 2026
Was this helpful?