Terraform Provisioners
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.
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
| Problem | Detail |
|---|---|
| Not idempotent | Terraform cannot check if a script already ran; re-running may fail or have side effects |
| Breaks declarative model | Scripts are imperative — Terraform cannot plan or diff them |
| State is not tracked | Only whether the provisioner succeeded, not what it changed |
| Taint on failure | A failed creation provisioner marks the resource tainted (destroyed on next apply) |
| Network dependency | remote-exec requires SSH/WinRM connectivity at creation time — fragile in automated pipelines |
| No destroy guarantee | when = destroy provisioners may not run if Terraform is interrupted |
Prefer these alternatives:
| Provisioner Use Case | Better Alternative |
|---|---|
| Install software on EC2 | EC2 user_data / cloud-init |
| Configure a server | AWS Systems Manager (SSM) Run Command |
| Install packages | Packer (bake AMI with packages pre-installed) |
| Run Ansible after provisioning | Trigger from CI/CD after terraform apply, not from provisioner |
| Run a script after resource creation | aws_lambda_invocation or EventBridge + Lambda |
3. local-exec Provisioner
Runs a command on the machine running Terraform (your laptop, CI runner, etc.):
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:
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}| Argument | Default | Options |
|---|---|---|
command | Required | Any shell command |
interpreter | OS shell | ["/bin/bash", "-c"], ["python3", "-c"], PowerShell |
working_dir | Terraform working dir | Any path |
environment | Inherits Terraform env | Map of key-value pairs |
when | create | create, destroy |
on_failure | fail | fail, continue |
Common local-exec patterns:
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:
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):
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:
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:
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:
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
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:
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.web9. 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:
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)
triggersgives 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:
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:
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
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
| Concept | Key Fact |
|---|---|
| Provisioners are | Last resort — break declarative model |
local-exec | Runs on the Terraform machine |
remote-exec | Runs on the created resource via SSH/WinRM |
file | Copies files to the remote resource via SSH/WinRM |
connection block | Required for remote-exec and file; lives in resource block |
Default when | create — runs during resource creation |
when = destroy | Runs before resource is destroyed |
Default on_failure | fail — marks resource tainted |
on_failure = continue | Logs warning; resource NOT tainted |
| Tainted resource | Destroyed and recreated on next apply |
terraform apply -replace | Modern way to force recreate (replaces terraform taint) |
null_resource | Fake resource used to run decoupled provisioners |
terraform_data | Modern replacement for null_resource (Terraform >= 1.4) |
triggers | Controls when null_resource re-runs |
| Best alternative | user_data / cloud-init for instance bootstrapping |
self reference | Inside provisioner, self refers to the enclosing resource |
Practice Questions4
Q1. What does the `local-exec` provisioner do in Terraform?
Select one answer before revealing.
Q2. A `remote-exec` provisioner fails during resource creation. What is the default behavior?
Select one answer before revealing.
Q3. What is a `null_resource` in Terraform and when would you use it?
Select one answer before revealing.
Q4. What is the purpose of the `connection` block inside a Terraform resource?
Select one answer before revealing.