
Last Reviewed for Accuracy by Ryan Fee on June 1, 2025.
TL;DR
local-exec, remote-exec, file) execute imperative scripts during a resource's lifecycle. HashiCorp officially calls them a "last resort."terraform plan can't see what scripts will do, their changes aren't tracked in state, and idempotency becomes your problem.terraform_data over null_resource for resource-less provisioning.Terraform provisioners are one of the most polarizing features in the Infrastructure as Code toolkit. They exist to bridge a fundamental gap between Terraform's declarative model and the messy, imperative reality of infrastructure management. However, HashiCorp—the creators of Terraform—explicitly recommends using them only as a "last resort."
This comprehensive pillar article consolidates everything you need to know about Terraform provisioners: what they are, when (rarely) to use them, why they're problematic, and most importantly, what alternatives exist. Whether you're using Terraform or the open-source OpenTofu fork, this guide will help you make informed decisions about provisioners in your infrastructure automation workflows.
Terraform provisioners allow you to execute scripts or specific actions on a local or remote machine during a resource's lifecycle—typically after creation or before destruction. They exist to perform tasks that don't map directly to Terraform's declarative model, such as:
In many ways, provisioners represent an acknowledgment that Terraform alone cannot handle every real-world infrastructure scenario. They're pragmatic escapes from the purely declarative world.
Terraform's core strength is its declarative nature: you define the desired state, and Terraform figures out how to achieve it. Provisioners break this model by introducing imperative commands. This philosophical tension is at the heart of why HashiCorp discourages their use.
When you use a provisioner, you're saying: "Terraform, create this resource, then run this arbitrary script that I'm responsible for managing." The implications ripple through your entire infrastructure:
Terraform includes three built-in provisioners (vendor-specific ones like Chef and Puppet were removed in Terraform 0.15).
The local-exec provisioner executes commands on the machine where Terraform itself is running, typically after a resource has been created.
Syntax:
resource "aws_instance" "web" {
ami = "ami-0c55b31ad2c456998"
instance_type = "t2.micro"
provisioner "local-exec" {
command = "echo Instance ${self.id} has IP ${self.public_ip} >> instance_ips.txt"
environment = {
INSTANCE_ID = self.id
PUBLIC_IP = self.public_ip
}
}
}Key Arguments:
command (required): The command to executeinterpreter: Specifies the shell interpreter (e.g., ["/bin/bash", "-c"])working_dir: Directory where the command runsenvironment: Map of environment variables to passwhen: When to run (create or destroy)on_failure: What to do on failure (fail or continue)Use Cases:
Important: local-exec doesn't require a connection block because it runs on the Terraform execution environment itself.
For detailed guidance on local-exec, see Guide to local-exec.
The remote-exec provisioner executes scripts or commands directly on a newly created remote resource via SSH or WinRM.
Syntax:
resource "aws_instance" "app_server" {
ami = "ami-0c55b31ad2c456998"
instance_type = "t2.micro"
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/your-private-key.pem")
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"sudo yum update -y",
"sudo yum install -y httpd",
"sudo systemctl start httpd",
"sudo systemctl enable httpd"
]
}
}Execution Methods:
# Method 1: Inline commands
provisioner "remote-exec" {
inline = [
"command1",
"command2"
]
}
# Method 2: Single script file
provisioner "remote-exec" {
script = "path/to/setup.sh"
}
# Method 3: Multiple script files (executed in order)
provisioner "remote-exec" {
scripts = [
"path/to/first_script.sh",
"path/to/second_script.sh"
]
}Connection Requirements:
Remote-exec requires a connection block to define SSH or WinRM access. The connection can be specified at the resource level (applying to all provisioners) or at the provisioner level (specific to that provisioner).
SSH Connection Example:
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
timeout = "5m"
}WinRM Connection Example:
connection {
type = "winrm"
user = "Administrator"
password = var.admin_password
host = self.public_ip
port = 5986
https = true
}For comprehensive guidance on remote-exec and connection configuration, see:
The file provisioner copies files or directories from your local machine to a newly created remote resource.
Syntax:
resource "aws_instance" "db_server" {
ami = "ami-0c55b31ad2c456998"
instance_type = "t2.micro"
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/your-private-key.pem")
host = self.public_ip
}
# Copy a file with content
provisioner "file" {
content = "db_host=${self.private_ip}"
destination = "/etc/myapp/db.ini"
}
# Copy a file from local path
provisioner "file" {
source = "configs/app.conf"
destination = "/etc/myapp/app.conf"
}
# Copy a directory
provisioner "file" {
source = "configs/"
destination = "/etc/myapp"
}
}Key Arguments:
source (local path) or content (inline text)—never bothdestination (required): Remote path where the file/directory should be placedwhen: When to run (create or destroy)on_failure: What to do on failure (fail or continue)Directory Transfer Behavior:
When copying directories, trailing slashes matter:
source = "local/dir" → contents copied to /remote/path/dirsource = "local/dir/" → contents copied directly into /remote/pathImportant Note: With SSH, the destination directory must already exist. Use remote-exec to create it first:
provisioner "remote-exec" {
inline = ["mkdir -p /opt/application/config"]
}
provisioner "file" {
source = "configs/"
destination = "/opt/application/config"
}For detailed guidance on file provisioners, see Guide to file provisioners.
| Feature | local-exec | remote-exec | file |
|---|---|---|---|
| Execution Locus | Terraform Host | Remote Resource | Remote Resource (copies from Local) |
| Primary Use Case | Run local scripts/commands | Software installation, service config | Copy files/directories to remote |
| Connection Required | No | Yes (SSH or WinRM) | Yes (SSH or WinRM) |
| Key Arguments | command, environment | inline, script, scripts | source/content, destination |
| Common Scenario | Triggering local build/notification scripts | Software installation on VM | Uploading config files for execution |

Provisioners execute at specific points in the resource lifecycle:
Creation-Time (Default Behavior: when = "create")
terraform applyDestroy-Time (when = "destroy")
create_before_destroy is enabled
Control how Terraform handles provisioner failures with the on_failure parameter:
| Timing | on_failure | Resource Status | Terraform Behavior |
|---|---|---|---|
| Creation-Time | fail (default) |
Tainted | Apply stops, error raised |
| Creation-Time | continue |
Tainted | Apply continues with warning |
| Destroy-Time | fail (default) |
Not destroyed | Provisioner reruns on next attempt |
| Destroy-Time | continue |
Destroyed | Apply continues with warning |
self ObjectWithin provisioner blocks, you can reference the parent resource using self:
provisioner "local-exec" {
command = "echo ${self.id} > resource_id.txt"
}
provisioner "remote-exec" {
connection {
host = self.public_ip
}
inline = ["echo 'Connected to ${self.id}'"]
}HashiCorp's "last resort" guidance isn't merely a suggestion—it reflects fundamental architectural challenges with provisioners. Understanding these challenges is crucial for making good infrastructure decisions.
Terraform's power comes from its declarative approach: you define the desired end state, and Terraform manages the journey. Provisioners break this model by introducing imperative scripts.
The Problem:
terraform plan cannot show you what changes a provisioner script will makeTerraform's state file is its source of truth for infrastructure. Provisioner actions are not recorded in state.
The Problem:
Example:
provisioner "remote-exec" {
inline = ["apt-get install -y nginx"]
}If someone later manually removes nginx, Terraform won't detect or reinstall it. The drift is invisible to your IaC system.
Terraform resources are idempotent: applying the same configuration multiple times produces the same result. Scripts are not idempotent by default.
The Problem:
Bad Example (Non-Idempotent):
#!/bin/bash
# This appends to a config file every time it runs
echo "config_value=123" >> /etc/myapp/config.confRunning this script twice results in the config entry appearing twice—probably not what you intended.
Better Example (Idempotent):
#!/bin/bash
set -e
# Check if already configured before adding
if ! grep -q "config_value=123" /etc/myapp/config.conf; then
echo "config_value=123" >> /etc/myapp/config.conf
fiProvisioners, especially remote-exec, open up direct command execution on your resources.
Credential Management:
Injection Vulnerabilities:
With local-exec, directly interpolating variables into commands is dangerous:
# VULNERABLE - command injection risk
provisioner "local-exec" {
command = "echo '${var.user_input}' > file.txt"
}If var.user_input contains '; rm -rf /; echo ', the consequences are catastrophic.
Safe Approach:
# SAFE - pass data via environment variables
provisioner "local-exec" {
command = "safe_script.sh"
environment = {
USER_INPUT = var.user_input
}
}When provisioner scripts fail, troubleshooting can be painful.
The Problems:
Never hardcode credentials:
# BAD
connection {
password = "hardcoded_password"
}
# GOOD - use variables marked sensitive
variable "admin_password" {
type = string
sensitive = true
}
connection {
password = var.admin_password
}Use Terraform 1.10+ ephemeral values (when available) to prevent credentials from being stored in state files:
variable "admin_password" {
type = string
sensitive = true
ephemeral = true
}Source secrets from external systems:
data "aws_secretsmanager_secret_version" "db_creds" {
secret_id = "prod/database/credentials"
}
locals {
db_creds = jsondecode(data.aws_secretsmanager_secret_version.db_creds.secret_string)
}
provisioner "remote-exec" {
environment = {
DB_USER = local.db_creds.username
DB_PASS = local.db_creds.password
}
inline = [
"bash /tmp/configure_db.sh"
]
}Restrict SSH/WinRM access:
resource "aws_security_group" "provisioning" {
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["${data.external.my_ip.result.ip}/32"] # Your IP only
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}Use bastion hosts for private resources:
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = self.private_ip
bastion_host = aws_instance.bastion.public_ip
bastion_user = "ec2-user"
bastion_private_key = file("~/.ssh/bastion_key")
}Make scripts defensive:
provisioner "remote-exec" {
inline = [
"set -euo pipefail", # Exit on error, undefined vars, pipe failures
"export DEBIAN_FRONTEND=noninteractive",
"if ! command -v nginx &> /dev/null; then",
" sudo apt-get update",
" sudo apt-get install -y nginx",
"fi",
"sudo systemctl enable nginx",
"sudo systemctl start nginx"
]
}If, after exhausting alternatives, a provisioner is absolutely required, follow these practices religiously:
# This provisioner integrates with our legacy ERP system (custom CLI only, no API)
# Alternatives considered: vendor API (doesn't exist), Lambda (insufficient permissions)
# This is acceptable as a documented exception under INFRA-POLICY-47
provisioner "local-exec" {
command = "legacy_erp_cli register --system-id=${self.id}"
}Every script must be safe to run multiple times without unintended side effects:
#!/bin/bash
set -e # Exit on any error
# Idempotent: check before modifying
if ! grep -q "DATABASE_HOST" /etc/app/config.env; then
echo "DATABASE_HOST=db.example.com" >> /etc/app/config.env
fi
# Idempotent: only install if not present
if ! command -v nginx &> /dev/null; then
apt-get update
apt-get install -y nginx
fi
# Idempotent: enable service (safe to run multiple times)
systemctl enable nginx
systemctl start nginxAlways use environment variables or external secrets systems:
data "aws_secretsmanager_secret_version" "api_key" {
secret_id = "prod/api-key"
}
provisioner "local-exec" {
command = "deploy.sh"
environment = {
API_KEY = jsondecode(data.aws_secretsmanager_secret_version.api_key.secret_string).key
}
}Scripts should clearly indicate success or failure:
#!/bin/bash
set -e
exec > >(tee -a /var/log/terraform-provisioner.log) 2>&1
echo "[$(date)] Starting provisioning..."
if ! apt-get update; then
echo "[$(date)] FATAL: apt-get update failed"
exit 1
fi
echo "[$(date)] Provisioning completed successfully"
exit 0# Use null_resource to test provisioner logic without affecting real infrastructure
resource "null_resource" "test_provisioner" {
provisioner "local-exec" {
command = "bash scripts/test.sh"
}
}If a provisioner script becomes complex, it's a sign you need a dedicated configuration management tool:
# BAD: Doing too much in the provisioner
provisioner "local-exec" {
command = "bash -c 'if [[ $PROD == true ]]; then ... complex setup ...; fi'"
}
# GOOD: Use a dedicated tool for complex logic
provisioner "local-exec" {
command = "ansible-playbook -i inventory playbook.yml"
}When you need provisioner logic not tied to a specific infrastructure resource, two options exist:
The null_resource from the null provider is a special resource that creates no actual infrastructure but can host provisioners.
resource "null_resource" "run_script_on_change" {
triggers = {
# Re-run when content changes
config_file_sha1 = filesha1("configs/my_config.json")
}
provisioner "local-exec" {
command = "echo 'Configuration changed: ${self.triggers.config_file_sha1}' && ./my_script.sh"
}
}Key Characteristics:
terraform { required_providers { null = {...} } })triggers map to control re-executionIntroduced in Terraform 1.4 (2023), terraform_data is a built-in alternative to null_resource:
resource "terraform_data" "cluster_setup" {
triggers_replace = aws_instance.cluster[*].id
provisioner "local-exec" {
command = "echo 'Cluster IPs: ${join(" ", aws_instance.cluster[*].private_ip)}'"
}
}Key Characteristics:
triggers_replace for cleaner controlinput/output attributes| Feature | null_resource | terraform_data |
|---|---|---|
| Provider | External null provider | Built-in |
| Trigger mechanism | triggers map |
triggers_replace |
| Data storage | No built-in storage | input/output attributes |
| First introduced | Early Terraform versions | Terraform 1.4 (2023) |
| Future direction | May be deprecated | Preferred going forward |
| OpenTofu support | Full | Still being added |
Recommendation: For new projects, prefer terraform_data. For existing projects, null_resource continues to work fine.
Orchestration across multiple resources:
resource "terraform_data" "initialize_cluster" {
triggers_replace = aws_instance.cluster[*].id
provisioner "remote-exec" {
connection {
host = aws_instance.cluster[0].public_ip
}
inline = [
"cluster-init.sh ${join(" ", aws_instance.cluster[*].private_ip)}"
]
}
}Running scripts conditionally based on data changes:
resource "terraform_data" "config_generator" {
triggers_replace = aws_db_instance.main.endpoint
provisioner "local-exec" {
command = "python generate_config.py --db-host=${aws_db_instance.main.address}"
}
}Cleanup on destruction:
resource "null_resource" "unmount_volume" {
triggers = {
volume_id = aws_ebs_volume.data.id
instance_ip = aws_instance.web.public_ip
}
provisioner "remote-exec" {
when = destroy
connection {
host = self.triggers.instance_ip
}
inline = [
"sudo umount /data",
"sudo sed -i '/\\/data/d' /etc/fstab"
]
}
}Before reaching for any provisioner, exhaustively evaluate these alternatives. They solve the same problems more declaratively and securely.
Cloud providers allow passing initialization scripts at instance launch:
resource "aws_instance" "web_via_userdata" {
ami = "ami-0c55b31ad2c456998"
instance_type = "t2.micro"
user_data = <<-EOF
#!/bin/bash
apt-get update -y
apt-get install -y nginx
systemctl enable nginx
systemctl start nginx
echo "<h1>Hello from User Data!</h1>" > /var/www/html/index.html
EOF
tags = {
Name = "web-via-userdata"
}
}Advantages:
Limitations:
Best For:
Use HashiCorp Packer to pre-bake software and configurations into machine images:
# With Packer, you build this image once
# This Packer configuration is stored separately from Terraform
# packer build packer.hcl → produces AMI: ami-0dbaca5d269497603
# Terraform simply references the pre-built image
resource "aws_instance" "web" {
ami = "ami-0dbaca5d269497603" # Pre-built with Packer
instance_type = "t2.micro"
}Advantages:
Disadvantages:
Best For:
Example Packer Workflow:
1. Develop packer/web-server.hcl
2. Run: packer build packer/web-server.hcl
3. Packer outputs AMI ID (e.g., ami-xyz123)
4. Reference in Terraform: ami = "ami-xyz123"
Dedicated tools like Ansible, Chef, Puppet, and SaltStack are purpose-built for configuration management:
resource "null_resource" "ansible_config" {
depends_on = [aws_instance.web]
provisioner "local-exec" {
command = "ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -u ec2-user -i '${aws_instance.web.public_ip},' --private-key ~/.ssh/key.pem playbook.yml"
}
}Advantages:
Disadvantages:
Best For:
Cloud providers offer native resources for many configuration tasks:
AWS Systems Manager:
resource "aws_ssm_document" "web_setup" {
name = "web-server-setup"
document_type = "Command"
content = jsonencode({
schemaVersion = "2.2"
description = "Setup web server"
mainSteps = [
{
action = "aws:runShellScript"
name = "InstallNginx"
inputs = {
runCommand = [
"apt-get update",
"apt-get install -y nginx",
"systemctl enable nginx",
"systemctl start nginx"
]
}
}
]
})
}
resource "aws_ssm_association" "web_setup" {
name = aws_ssm_document.web_setup.name
targets {
key = "InstanceIds"
values = [aws_instance.web.id]
}
}Advantages:
Best For:
| Alternative | How It Works | Pros | Cons | Primary Use Cases |
|---|---|---|---|---|
| Cloud-Init / User Data | Scripts/directives executed by instance at boot | Native to cloud; avoids Terraform-to-instance network dependency; good for initial bootstrap | Limited to boot time; can become complex; debugging tricky | Initial package installs, SSH setup, basic service config |
| Custom Machine Images (Packer) | Pre-bake software and configs into images; Terraform launches from image | Faster startup; immutable; consistent deployments; reduced runtime config errors | Image build pipeline required; image management overhead; updates require rebuild | Golden images, standardized OS, fast deployments, auto-scaling |
| Config Management Tools (Ansible, Chef, etc.) | Dedicated tools for software installation and system configuration | Robust, idempotent; mature ecosystems; designed for drift detection | Another tool to manage; learning curve; can be slower initially | Complex application setup, ongoing management, compliance |
| Provider-Specific Resources | Use Terraform resources to manage configurations natively | Declarative; integrated with state; often most reliable | Limited to what provider exposes; may not cover custom needs | Service-specific settings (database params, load balancer rules) |
Golden Rule: Evaluate alternatives in this order:
Despite the warnings, legitimate scenarios exist where provisioners are appropriate:
1. Integrating with Legacy Systems
resource "aws_instance" "erp_connector" {
ami = "ami-0c55b31ad2c456998"
instance_type = "t2.micro"
}
resource "null_resource" "erp_registration" {
triggers = {
instance_id = aws_instance.erp_connector.id
instance_ip = aws_instance.erp_connector.private_ip
}
provisioner "local-exec" {
command = <<-EOT
# Custom script to register with legacy ERP system (CLI-only, no API)
python register_with_erp.py \
--system-name="AWS-Connector-${aws_instance.erp_connector.id}" \
--system-ip=${aws_instance.erp_connector.private_ip}
EOT
}
provisioner "local-exec" {
when = destroy
command = "python deregister_from_erp.py --system-id=${self.triggers.instance_id}"
}
}Reasoning:
2. Cluster Orchestration
resource "aws_instance" "k8s_master" {
ami = "ami-0c55b31ad2c456998"
instance_type = "t2.medium"
user_data = base64encode(file("${path.module}/master-init.sh"))
}
resource "aws_instance" "k8s_worker" {
count = 3
ami = "ami-0c55b31ad2c456998"
instance_type = "t2.medium"
user_data = base64encode(templatefile("${path.module}/worker-init.sh.tpl", {
master_ip = aws_instance.k8s_master.private_ip
}))
depends_on = [aws_instance.k8s_master]
}
resource "terraform_data" "initialize_cluster" {
triggers_replace = aws_instance.k8s_worker[*].id
provisioner "remote-exec" {
connection {
host = aws_instance.k8s_master.public_ip
}
inline = [
"kubeadm init --pod-network-cidr=10.244.0.0/16",
"kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml"
]
}
depends_on = [aws_instance.k8s_worker]
}Reasoning:
3. Database Migration and Schema Management
resource "aws_db_instance" "main" {
allocated_storage = 20
engine = "postgres"
instance_class = "db.t3.micro"
# ... configuration ...
}
resource "null_resource" "db_migration" {
triggers = {
schema_version = var.schema_version
db_endpoint = aws_db_instance.main.endpoint
}
provisioner "local-exec" {
command = <<-EOT
# Wait for database to be fully available
until psql -h ${aws_db_instance.main.address} -U ${aws_db_instance.main.username} -d ${aws_db_instance.main.name} -c "SELECT 1"; do
echo "Waiting for database connection..."
sleep 5
done
# Run migrations up to current schema version
DB_HOST=${aws_db_instance.main.address} \
DB_PORT=${aws_db_instance.main.port} \
DB_NAME=${aws_db_instance.main.name} \
DB_USER=${aws_db_instance.main.username} \
DB_PASS=${random_password.db_password.result} \
./migrate.sh up
EOT
}
depends_on = [aws_db_instance.main]
}Reasoning:
❌ Installing basic software:
# BAD: Use cloud-init instead
provisioner "remote-exec" {
inline = [
"apt-get update",
"apt-get install -y nginx"
]
}
# GOOD: Use cloud-init/user-data
user_data = <<-EOF
#!/bin/bash
apt-get update -y
apt-get install -y nginx
systemctl start nginx
EOF❌ Uploading simple config files:
# BAD: Use file provisioner and remote-exec
provisioner "file" {
source = "nginx.conf"
destination = "/tmp/nginx.conf"
}
provisioner "remote-exec" {
inline = ["sudo cp /tmp/nginx.conf /etc/nginx/nginx.conf"]
}
# GOOD: Use cloud-init with inline content
user_data = templatefile("${path.module}/init.tpl", {
nginx_config = file("${path.module}/nginx.conf")
})❌ Deploying applications at scale:
# BAD: remote-exec for each instance
provisioner "remote-exec" {
inline = [
"git clone https://github.com/myapp.git",
"npm install",
"npm start"
]
}
# GOOD: Packer builds image with app pre-installed
resource "aws_instance" "app" {
ami = aws_ami.app.id # Built with Packer
}Managing provisioners becomes increasingly difficult as infrastructure scales. Modern IaC management platforms like Scalr address these challenges by providing:
Centralized Execution:
Credential Injection:
Execution Logging:
Policy Enforcement:
Scalable Orchestration:
OpenTofu, the open-source fork of Terraform maintained by the community, provides full support for all provisioner types:
local-exec - Fully supportedremote-exec - Fully supportedfile - Fully supportednull_resource - Fully supportedterraform_data - Being added (use null_resource as fallback)All guidance in this pillar applies equally to OpenTofu deployments.
Terraform provisioners are escape hatches from the purely declarative world. They exist because infrastructure is messy and sometimes you need to perform imperative actions that don't fit Terraform's model.
However, their existence shouldn't diminish the pursuit of declarative, manageable infrastructure:
Key Takeaways:
terraform_data over null_resource for new projects and resourceless provisioning.The goal of Infrastructure as Code isn't to run scripts—it's to define, version, and manage infrastructure reliably, predictably, and at scale. Provisioners should be the narrow exception, not the foundation of your automation strategy.
