Terraform Provisioners
Provisioners run scripts on a local machine or remote resource after a resource is created or before it’s destroyed. They’re a last resort for bootstrap tasks that can’t be handled by the provider API. Terraform itself recommends avoiding provisioners when possible — they make plans unreliable and are difficult to debug.
When Provisioners Are Appropriate
Use a provisioner only when:
- The provider has no native support for the operation
- Cloud-init or user data isn’t available or sufficient
- You need to run a command during the destroy phase
Prefer over provisioners:
aws_instanceuser_datafor EC2 bootstrap- Cloud-init for VM configuration
- AWS Systems Manager for post-deployment commands
- Packer for building custom AMIs with pre-installed software
local-exec
Runs a command on the machine running Terraform (not on the resource):
resource "aws_instance" "web" { ami = data.aws_ami.ubuntu.id instance_type = "t3.micro"
provisioner "local-exec" { command = <<-EOT echo "Instance ${self.id} created at ${self.public_ip}" >> instances.log EOT }}# Run Ansible after Terraform creates the hostresource "aws_instance" "app" { ami = data.aws_ami.ubuntu.id instance_type = "t3.micro"
provisioner "local-exec" { command = <<-EOT sleep 30 ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook \ -i "${self.public_ip}," \ -u ubuntu \ --private-key ~/.ssh/deploy-key \ playbooks/configure-app.yml EOT environment = { APP_VERSION = var.app_version ENVIRONMENT = var.environment } }}# Run on destroy (when = destroy)resource "aws_instance" "worker" { # ...
provisioner "local-exec" { when = destroy command = "curl -X DELETE https://api.myapp.com/workers/${self.id}" }}remote-exec
Runs a command directly on the resource (requires SSH or WinRM connectivity):
resource "aws_instance" "web" { ami = data.aws_ami.ubuntu.id instance_type = "t3.micro" key_name = aws_key_pair.deploy.key_name
# Required: connection block tells Terraform how to SSH in connection { type = "ssh" user = "ubuntu" private_key = file("~/.ssh/deploy-key") host = self.public_ip }
provisioner "remote-exec" { inline = [ "sudo apt-get update -y", "sudo apt-get install -y nginx", "sudo systemctl enable nginx", "sudo systemctl start nginx" ] }}# Script file instead of inlineprovisioner "remote-exec" { script = "./scripts/configure-app.sh"}
# Multiple scriptsprovisioner "remote-exec" { scripts = [ "./scripts/install-dependencies.sh", "./scripts/configure-app.sh", "./scripts/start-services.sh" ]}file Provisioner
Copies files or directories to the resource:
resource "aws_instance" "web" { # ...
connection { type = "ssh" user = "ubuntu" private_key = file("~/.ssh/deploy-key") host = self.public_ip }
# Copy a single file provisioner "file" { source = "configs/nginx.conf" destination = "/tmp/nginx.conf" }
# Copy a directory provisioner "file" { source = "app/" destination = "/home/ubuntu/app" }
# Copy a template string (rendered locally) provisioner "file" { content = templatefile("${path.module}/configs/app.conf.tpl", { db_host = aws_db_instance.main.address app_port = var.app_port }) destination = "/etc/app/app.conf" }
# Run file provisioners before remote-exec provisioner "remote-exec" { inline = [ "sudo mv /tmp/nginx.conf /etc/nginx/nginx.conf", "sudo systemctl reload nginx" ] }}Connection Block
# SSH (Linux)connection { type = "ssh" user = "ubuntu" private_key = file("~/.ssh/id_rsa") host = self.public_ip
# Optional settings timeout = "10m" port = 22 agent = false
# Bastion host jump bastion_host = var.bastion_host bastion_user = "ec2-user" bastion_private_key = file("~/.ssh/bastion-key")}
# WinRM (Windows)connection { type = "winrm" user = "Administrator" password = var.admin_password host = self.public_ip https = true insecure = true port = 5986}Failure Behavior
resource "aws_instance" "web" { # ...
provisioner "remote-exec" { on_failure = continue # Don't fail the resource if this provisioner fails inline = ["optional-setup.sh"] }
provisioner "remote-exec" { on_failure = fail # Default — resource creation fails if provisioner fails inline = ["critical-setup.sh"] }}When a provisioner fails and on_failure = fail (default), the resource is marked as “tainted” in state — the next terraform apply will destroy and recreate it.
null_resource + Triggers
Use null_resource when you need to run a provisioner that isn’t tied to a specific cloud resource:
resource "null_resource" "run_migrations" { # Re-run when the database endpoint or migration hash changes triggers = { db_endpoint = aws_db_instance.main.endpoint migration_hash = filemd5("migrations/V001__init.sql") }
provisioner "local-exec" { command = <<-EOT flyway -url=jdbc:postgresql://${aws_db_instance.main.endpoint}/app migrate EOT environment = { FLYWAY_PASSWORD = var.database_password } }}