Cloud  /  Terraform

IaC Terraform 50 guides · updated 2026

Infrastructure as code done right — providers, state, reusable modules, and the workflow patterns that keep multi-cloud deployments sane in 2026.

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:

Prefer over provisioners:


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 host
resource "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 inline
provisioner "remote-exec" {
script = "./scripts/configure-app.sh"
}
# Multiple scripts
provisioner "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
}
}
}