/Terraform Provisioners
Concept
Medium

Terraform Provisioners

9 min read·provisionerslocal-execremote-execfile-provisionerconnectionnull-resourcetaintedwhen-destroyon-failureterraform-associate

Provisioners run scripts on local or remote machines during resource creation or destruction. They are a deliberate last resort — the exam tests all three types (local-exec, remote-exec, file), the connection block, when/on_failure behaviors, the tainted resource lifecycle, null_resource with triggers, and why cloud-native alternatives (user_data, cloud-init) are always preferred.


1. What are Provisioners?

Provisioners are a mechanism to execute scripts or commands on a local machine (where Terraform runs) or a remote machine (newly created resource) as part of resource creation or destruction.

Rendering diagram…

HashiCorp's guidance: Provisioners are a last resort. They break Terraform's declarative model — they are imperative scripts that Terraform cannot model or diff. Prefer cloud-native alternatives whenever possible.


2. Why Provisioners are a Last Resort

ProblemDetail
Not idempotentTerraform cannot check if a script already ran; re-running may fail or have side effects
Breaks declarative modelScripts are imperative — Terraform cannot plan or diff them
State is not trackedOnly whether the provisioner succeeded, not what it changed
Taint on failureA failed creation provisioner marks the resource tainted (destroyed on next apply)
Network dependencyremote-exec requires SSH/WinRM connectivity at creation time — fragile in automated pipelines
No destroy guaranteewhen = destroy provisioners may not run if Terraform is interrupted

Prefer these alternatives:

Provisioner Use CaseBetter Alternative
Install software on EC2EC2 user_data / cloud-init
Configure a serverAWS Systems Manager (SSM) Run Command
Install packagesPacker (bake AMI with packages pre-installed)
Run Ansible after provisioningTrigger from CI/CD after terraform apply, not from provisioner
Run a script after resource creationaws_lambda_invocation or EventBridge + Lambda

3. local-exec Provisioner

Runs a command on the machine running Terraform (your laptop, CI runner, etc.):

hcl
1resource "aws_instance" "web" {
2  ami           = data.aws_ami.ubuntu.id
3  instance_type = "t3.micro"
4
5  provisioner "local-exec" {
6    command = "echo ${self.public_ip} >> ./inventory.txt"
7  }
8}

All local-exec arguments:

hcl
1provisioner "local-exec" {
2  command     = "ansible-playbook -i '${self.public_ip},' playbook.yml"
3
4  # Optional arguments:
5  interpreter = ["/bin/bash", "-c"]   # default: OS shell
6  working_dir = "${path.module}"      # directory to run command in
7  environment = {
8    HOST        = self.public_ip
9    ENV         = var.environment
10    SECRET_KEY  = var.api_key         # inject env vars
11  }
12  when       = create   # default; or "destroy"
13  on_failure = fail     # default; or "continue"
14}
ArgumentDefaultOptions
commandRequiredAny shell command
interpreterOS shell["/bin/bash", "-c"], ["python3", "-c"], PowerShell
working_dirTerraform working dirAny path
environmentInherits Terraform envMap of key-value pairs
whencreatecreate, destroy
on_failurefailfail, continue

Common local-exec patterns:

hcl
1# Write resource IP to Ansible inventory
2provisioner "local-exec" {
3  command = "echo '[web]\n${self.public_ip}' > inventory.ini"
4  interpreter = ["/bin/bash", "-c"]
5}
6
7# Trigger Ansible playbook
8provisioner "local-exec" {
9  command     = "ansible-playbook -i '${self.public_ip},' -u ubuntu --private-key ~/.ssh/id_rsa playbook.yml"
10  working_dir = "${path.module}/ansible"
11}
12
13# Call an external API after creation
14provisioner "local-exec" {
15  command = "curl -X POST https://api.example.com/register -d '{"ip":"${self.public_ip}"}'"
16}
17
18# Windows PowerShell
19provisioner "local-exec" {
20  command     = "Write-Host 'Instance ready: ${self.id}'"
21  interpreter = ["PowerShell", "-Command"]
22}

4. remote-exec Provisioner

Runs commands directly on the resource via SSH (Linux) or WinRM (Windows). Requires a connection block:

hcl
1resource "aws_instance" "web" {
2  ami                         = data.aws_ami.ubuntu.id
3  instance_type               = "t3.micro"
4  key_name                    = aws_key_pair.deployer.key_name
5  vpc_security_group_ids      = [aws_security_group.ssh.id]
6  associate_public_ip_address = true
7
8  connection {
9    type        = "ssh"
10    user        = "ubuntu"
11    private_key = file("~/.ssh/id_rsa")
12    host        = self.public_ip
13    timeout     = "5m"   # wait up to 5 min for SSH to become available
14  }
15
16  provisioner "remote-exec" {
17    inline = [
18      "sudo apt-get update -y",
19      "sudo apt-get install -y nginx",
20      "sudo systemctl enable nginx",
21      "sudo systemctl start nginx",
22    ]
23  }
24}

remote-exec content options (choose one):

hcl
1# Option 1: inline — list of commands run sequentially
2provisioner "remote-exec" {
3  inline = [
4    "sudo apt-get update",
5    "sudo apt-get install -y docker.io",
6  ]
7}
8
9# Option 2: script — copy a local script and execute it
10provisioner "remote-exec" {
11  script = "${path.module}/scripts/setup.sh"
12}
13
14# Option 3: scripts — multiple local scripts, run in order
15provisioner "remote-exec" {
16  scripts = [
17    "${path.module}/scripts/install.sh",
18    "${path.module}/scripts/configure.sh",
19    "${path.module}/scripts/start.sh",
20  ]
21}

5. The connection Block

The connection block defines how Terraform connects to the remote resource. It lives inside the resource, not inside the provisioner:

hcl
1# SSH connection (Linux)
2connection {
3  type        = "ssh"           # default
4  user        = "ubuntu"        # or "ec2-user", "admin", "centos"
5  private_key = file("~/.ssh/id_rsa")
6  host        = self.public_ip  # self = the resource containing this block
7  port        = 22              # default
8  timeout     = "10m"           # how long to wait for connection
9  agent       = false           # use SSH agent forwarding
10}
11
12# WinRM connection (Windows)
13connection {
14  type     = "winrm"
15  user     = "Administrator"
16  password = var.windows_password
17  host     = self.public_ip
18  port     = 5985   # or 5986 for HTTPS
19  https    = false
20  insecure = true   # skip TLS verification (dev only)
21  timeout  = "10m"
22}
23
24# Using a bastion host (jump host)
25connection {
26  type         = "ssh"
27  user         = "ubuntu"
28  private_key  = file("~/.ssh/id_rsa")
29  host         = self.private_ip       # private IP — not reachable directly
30  bastion_host = aws_instance.bastion.public_ip
31  bastion_user = "ubuntu"
32  bastion_private_key = file("~/.ssh/id_rsa")
33}

6. file Provisioner

Copies files or directories from the Terraform machine to the remote resource via SSH/WinRM:

hcl
1resource "aws_instance" "web" {
2  # ... ami, instance_type, etc.
3
4  connection {
5    type        = "ssh"
6    user        = "ubuntu"
7    private_key = file("~/.ssh/id_rsa")
8    host        = self.public_ip
9  }
10
11  # Copy a single file
12  provisioner "file" {
13    source      = "${path.module}/configs/nginx.conf"
14    destination = "/tmp/nginx.conf"
15  }
16
17  # Copy a directory
18  provisioner "file" {
19    source      = "${path.module}/app/"
20    destination = "/opt/app"
21  }
22
23  # Copy inline content (no source file needed)
24  provisioner "file" {
25    content     = templatefile("${path.module}/templates/app.conf.tpl", {
26      port = var.app_port
27      env  = var.environment
28    })
29    destination = "/etc/app/app.conf"
30  }
31
32  # Then execute the copied file
33  provisioner "remote-exec" {
34    inline = [
35      "chmod +x /tmp/setup.sh",
36      "sudo /tmp/setup.sh",
37    ]
38  }
39}

7. Provisioner Timing: when

By default, provisioners run at resource creation. Use when = destroy to run at destruction:

hcl
1resource "aws_instance" "web" {
2  ami           = data.aws_ami.ubuntu.id
3  instance_type = "t3.micro"
4
5  # Runs when resource is CREATED (default)
6  provisioner "local-exec" {
7    command = "echo 'Instance ${self.id} created' >> lifecycle.log"
8    when    = create   # explicit but same as default
9  }
10
11  # Runs when resource is DESTROYED
12  provisioner "local-exec" {
13    command = "echo 'Instance ${self.id} destroyed' >> lifecycle.log"
14    when    = destroy
15  }
16}

Important: Destroy-time provisioners run before the resource is destroyed. If the provisioner fails and on_failure = fail, the destroy is aborted and the resource remains.


8. on_failure Behavior & Tainted Resources

Rendering diagram…
hcl
1provisioner "remote-exec" {
2  inline     = ["sudo systemctl start myapp"]
3  on_failure = fail      # default: marks resource tainted on failure
4}
5
6provisioner "remote-exec" {
7  inline     = ["sudo systemctl start optional-service || true"]
8  on_failure = continue  # log warning, don't taint resource
9}

Tainted resource:

  • Marked in state as tainted when a creation provisioner fails
  • On the next terraform apply, Terraform will destroy and recreate the tainted resource
  • You can manually taint/untaint:
bash
1# Mark a resource as tainted (force destroy+recreate on next apply)
2terraform taint aws_instance.web         # deprecated syntax
3terraform apply -replace=aws_instance.web  # preferred modern syntax
4
5# Remove taint mark (keep existing resource as-is)
6terraform untaint aws_instance.web

9. null_resource — Decoupled Provisioning

```null_resource`` is a special resource with no real infrastructure — it exists only to run provisioners. The triggers map controls when it re-runs:

hcl
1resource "null_resource" "ansible_provision" {
2  # Re-run provisioner whenever any trigger value changes
3  triggers = {
4    instance_id      = aws_instance.web.id
5    playbook_hash    = filemd5("${path.module}/ansible/playbook.yml")
6    config_hash      = filemd5("${path.module}/ansible/vars.yml")
7  }
8
9  provisioner "local-exec" {
10    command = <<-EOT
11      ansible-playbook         -i '${aws_instance.web.public_ip},'         -u ubuntu         --private-key ~/.ssh/id_rsa         ${path.module}/ansible/playbook.yml
12    EOT
13
14    environment = {
15      ANSIBLE_HOST_KEY_CHECKING = "False"
16      ENV                       = var.environment
17    }
18  }
19
20  # Explicit dependency — wait for instance to be ready
21  depends_on = [aws_instance.web]
22}

Why null_resource is better than embedding provisioners in resources:

  • Separates infrastructure creation from configuration
  • Can be re-triggered independently (without destroying the resource)
  • triggers gives explicit control over when provisioner re-runs
  • Cleaner separation of concerns — IaC vs configuration management

terraform_data (Terraform >= 1.4) — modern replacement for null_resource:

hcl
1resource "terraform_data" "ansible_provision" {
2  triggers_replace = [
3    aws_instance.web.id,
4    filemd5("${path.module}/ansible/playbook.yml")
5  ]
6
7  provisioner "local-exec" {
8    command = "ansible-playbook -i '${aws_instance.web.public_ip},' playbook.yml"
9  }
10}

10. Multiple Provisioners

A resource can have multiple provisioners — they run in the order declared:

hcl
1resource "aws_instance" "app" {
2  ami           = data.aws_ami.ubuntu.id
3  instance_type = "t3.micro"
4
5  connection {
6    type        = "ssh"
7    user        = "ubuntu"
8    private_key = file("~/.ssh/id_rsa")
9    host        = self.public_ip
10  }
11
12  # Step 1: Copy application config
13  provisioner "file" {
14    source      = "app.conf"
15    destination = "/tmp/app.conf"
16  }
17
18  # Step 2: Copy setup script
19  provisioner "file" {
20    source      = "setup.sh"
21    destination = "/tmp/setup.sh"
22  }
23
24  # Step 3: Execute setup script
25  provisioner "remote-exec" {
26    inline = [
27      "chmod +x /tmp/setup.sh",
28      "sudo /tmp/setup.sh",
29    ]
30  }
31
32  # Step 4: Notify external system (runs locally)
33  provisioner "local-exec" {
34    command = "curl -X POST https://deploy.example.com/notify -d 'host=${self.public_ip}'"
35  }
36}

11. Preferred Alternatives — Cloud-Native Patterns

hcl
1# PREFERRED: user_data (runs at instance boot, no SSH needed)
2resource "aws_instance" "web" {
3  ami           = data.aws_ami.ubuntu.id
4  instance_type = "t3.micro"
5
6  user_data = <<-EOT
7    #!/bin/bash
8    apt-get update -y
9    apt-get install -y nginx
10    systemctl enable nginx
11    systemctl start nginx
12    echo "<h1>Hello from $(hostname)</h1>" > /var/www/html/index.html
13  EOT
14
15  # No provisioner needed — user_data is baked into the instance boot
16}
17
18# PREFERRED: templatefile for dynamic user_data
19resource "aws_instance" "web" {
20  ami       = data.aws_ami.ubuntu.id
21  user_data = templatefile("${path.module}/templates/user_data.sh.tpl", {
22    environment = var.environment
23    app_version = var.app_version
24    db_host     = aws_db_instance.main.endpoint
25  })
26}
27
28# PREFERRED: Launch template for Auto Scaling Groups
29resource "aws_launch_template" "web" {
30  image_id      = data.aws_ami.ubuntu.id
31  instance_type = "t3.micro"
32
33  user_data = base64encode(templatefile("${path.module}/templates/user_data.sh.tpl", {
34    environment = var.environment
35  }))
36}

12. Quick Reference

ConceptKey Fact
Provisioners areLast resort — break declarative model
local-execRuns on the Terraform machine
remote-execRuns on the created resource via SSH/WinRM
fileCopies files to the remote resource via SSH/WinRM
connection blockRequired for remote-exec and file; lives in resource block
Default whencreate — runs during resource creation
when = destroyRuns before resource is destroyed
Default on_failurefail — marks resource tainted
on_failure = continueLogs warning; resource NOT tainted
Tainted resourceDestroyed and recreated on next apply
terraform apply -replaceModern way to force recreate (replaces terraform taint)
null_resourceFake resource used to run decoupled provisioners
terraform_dataModern replacement for null_resource (Terraform >= 1.4)
triggersControls when null_resource re-runs
Best alternativeuser_data / cloud-init for instance bootstrapping
self referenceInside provisioner, self refers to the enclosing resource

Practice Questions4

medium

Q1. What does the `local-exec` provisioner do in Terraform?


Select one answer before revealing.

medium

Q2. A `remote-exec` provisioner fails during resource creation. What is the default behavior?


Select one answer before revealing.

hard

Q3. What is a `null_resource` in Terraform and when would you use it?


Select one answer before revealing.

medium

Q4. What is the purpose of the `connection` block inside a Terraform resource?


Select one answer before revealing.