Terraform Provisioners
Terraform provisioners, null resources, triggers, and when to use alternatives
You are an expert in Terraform provisioners and null resources for infrastructure as code. ## Key Points - **`local-exec`** runs a command on the machine executing Terraform (the CI runner or your laptop). - **`remote-exec`** runs a command on the resource being created (requires SSH or WinRM connectivity). - **`file`** copies files or directories to the remote resource. - **Prefer user data, cloud-init, or AMI baking** over `remote-exec` for instance bootstrapping. User data runs without SSH access and is idempotent by design. - **Use `terraform_data` instead of `null_resource`** for Terraform 1.4+. It is built-in and does not require the null provider. - **Set `on_failure = continue`** on destroy-time provisioners. If the resource is already gone, the provisioner will fail and block `terraform destroy`. - **Use `triggers` or `triggers_replace` intentionally.** Make triggers depend on values that genuinely indicate when the provisioner should re-run. - **Keep provisioner scripts idempotent.** Terraform may re-run them during recovery from partial failures. - **Avoid storing secrets in provisioner commands.** Use the `environment` argument to pass secrets, which keeps them out of the plan output. - **Use `working_dir`** to control the execution context of `local-exec`. - **Provisioners are not shown in `terraform plan`.** You will not see what a provisioner will do during the plan phase. This makes them harder to review and audit. - **`remote-exec` requires network access.** If the instance is in a private subnet with no bastion or VPN, the SSH connection will time out. Use a bastion host or switch to `local-exec` with SSM.
skilldb get terraform-skills/Terraform ProvisionersFull skill: 273 linesProvisioners — Terraform
You are an expert in Terraform provisioners and null resources for infrastructure as code.
Overview
Provisioners execute scripts or commands on local or remote machines as part of the resource lifecycle. They are a "last resort" mechanism for tasks that Terraform's declarative model cannot express, such as bootstrapping a VM with a script, running database migrations, or triggering an external process. HashiCorp explicitly recommends avoiding provisioners when alternatives exist (user data, configuration management tools, or provider-specific resources).
Core Concepts
Provisioner Types
local-execruns a command on the machine executing Terraform (the CI runner or your laptop).remote-execruns a command on the resource being created (requires SSH or WinRM connectivity).filecopies files or directories to the remote resource.
Null Resource and terraform_data
The null_resource (from the null provider) and the built-in terraform_data resource (Terraform 1.4+) are containers for provisioners when you need to run scripts that are not tied to a specific infrastructure resource.
Implementation Patterns
local-exec Provisioner
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
provisioner "local-exec" {
command = "echo 'Instance ${self.id} created at ${self.public_ip}' >> instances.log"
}
}
# Run a script after creating an EKS cluster
resource "aws_eks_cluster" "main" {
name = "my-cluster"
role_arn = aws_iam_role.eks.arn
vpc_config {
subnet_ids = var.subnet_ids
}
provisioner "local-exec" {
command = <<-EOT
aws eks update-kubeconfig \
--name ${self.name} \
--region ${var.region}
kubectl apply -f ${path.module}/manifests/namespace.yaml
EOT
interpreter = ["/bin/bash", "-c"]
}
}
remote-exec Provisioner
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
key_name = aws_key_pair.deployer.key_name
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/deploy_key")
host = self.public_ip
timeout = "5m"
}
provisioner "remote-exec" {
inline = [
"sudo apt-get update -qq",
"sudo apt-get install -y -qq docker.io",
"sudo systemctl enable docker",
"sudo systemctl start docker",
]
}
}
file Provisioner
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
key_name = aws_key_pair.deployer.key_name
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/deploy_key")
host = self.public_ip
}
provisioner "file" {
source = "${path.module}/scripts/setup.sh"
destination = "/tmp/setup.sh"
}
provisioner "remote-exec" {
inline = [
"chmod +x /tmp/setup.sh",
"sudo /tmp/setup.sh",
]
}
}
Destroy-Time Provisioner
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
provisioner "local-exec" {
when = destroy
command = "python3 scripts/deregister.py --instance-id ${self.id}"
# Continue even if the script fails
on_failure = continue
}
}
null_resource with Triggers
resource "null_resource" "db_migration" {
triggers = {
# Re-run when the migration script changes
migration_hash = filemd5("${path.module}/migrations/latest.sql")
# Or re-run when the database endpoint changes
db_endpoint = aws_db_instance.main.endpoint
}
provisioner "local-exec" {
command = <<-EOT
psql "${aws_db_instance.main.endpoint}" \
-U ${var.db_username} \
-f ${path.module}/migrations/latest.sql
EOT
environment = {
PGPASSWORD = var.db_password
}
}
depends_on = [aws_db_instance.main]
}
terraform_data (Terraform 1.4+)
terraform_data replaces null_resource without needing an external provider.
resource "terraform_data" "configure_app" {
triggers_replace = [
aws_instance.app.id,
filemd5("${path.module}/scripts/configure.sh"),
]
input = aws_instance.app.public_ip
provisioner "local-exec" {
command = "bash ${path.module}/scripts/configure.sh ${self.input}"
}
}
Ansible Integration Pattern
resource "aws_instance" "app" {
count = var.instance_count
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
key_name = aws_key_pair.deployer.key_name
}
resource "local_file" "ansible_inventory" {
content = templatefile("${path.module}/templates/inventory.tftpl", {
instances = aws_instance.app
})
filename = "${path.module}/generated/inventory.ini"
}
resource "terraform_data" "ansible_playbook" {
triggers_replace = [
local_file.ansible_inventory.content,
filemd5("${path.module}/ansible/playbook.yml"),
]
provisioner "local-exec" {
command = "ansible-playbook -i ${path.module}/generated/inventory.ini ${path.module}/ansible/playbook.yml"
working_dir = path.module
}
depends_on = [local_file.ansible_inventory]
}
Templated User Data (Preferred Over remote-exec)
# Better alternative to remote-exec for initial instance configuration
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
user_data = templatefile("${path.module}/templates/userdata.tftpl", {
docker_version = var.docker_version
app_image = var.app_image
app_port = var.app_port
environment = var.environment
})
user_data_replace_on_change = true
}
Best Practices
- Prefer user data, cloud-init, or AMI baking over
remote-execfor instance bootstrapping. User data runs without SSH access and is idempotent by design. - Use
terraform_datainstead ofnull_resourcefor Terraform 1.4+. It is built-in and does not require the null provider. - Set
on_failure = continueon destroy-time provisioners. If the resource is already gone, the provisioner will fail and blockterraform destroy. - Use
triggersortriggers_replaceintentionally. Make triggers depend on values that genuinely indicate when the provisioner should re-run. - Keep provisioner scripts idempotent. Terraform may re-run them during recovery from partial failures.
- Avoid storing secrets in provisioner commands. Use the
environmentargument to pass secrets, which keeps them out of the plan output. - Use
working_dirto control the execution context oflocal-exec.
Core Philosophy
Provisioners are Terraform's escape hatch, not its primary tool. They exist for the narrow set of tasks that cannot be expressed declaratively: bootstrapping a VM before a configuration management tool takes over, running a one-time database migration, or triggering an external webhook. Every time you reach for a provisioner, the first question should be whether a provider-native resource, a data source, or an external tool can accomplish the same thing more reliably.
The fundamental tension with provisioners is that they introduce imperative, side-effect-driven behavior into a declarative system. Terraform cannot plan what a provisioner will do, cannot detect drift from what it did, and cannot roll back its effects. This makes provisioners invisible to the safety mechanisms that make Terraform valuable. Accepting this trade-off is sometimes necessary, but it should always be a conscious, documented decision rather than a default approach.
Idempotency is the single most important property of any provisioner script. Because Terraform may re-run provisioners during recovery from partial failures, and because null_resource triggers can fire unexpectedly, every script must be safe to execute multiple times without producing duplicate side effects. If a script cannot be made idempotent, it probably should not be a provisioner.
Anti-Patterns
-
Using
remote-execfor ongoing configuration management. Provisioners run once at creation time. If you need to keep a VM's software up to date, install patches, or rotate configurations, use Ansible, Chef, Puppet, or baked AMIs. Provisioners are not a replacement for configuration management. -
Embedding secrets in provisioner commands. Passing database passwords, API keys, or tokens directly in the
commandstring exposes them in Terraform state, CLI output, and process listings. Use theenvironmentargument to pass secrets, which keeps them out of the plan and logs. -
Chaining many provisioners on a single resource. Stacking five
local-execprovisioners on an instance creates a fragile, opaque setup script that is hard to debug and impossible to test independently. Extract complex provisioning logic into standalone scripts or move to a configuration management tool. -
Using
null_resourcewithout triggers. Anull_resourcewith notriggersblock runs once and never again, even if the script it executes has changed. Without triggers, there is no mechanism to detect when the provisioner needs to re-run, leaving infrastructure in an unknown state. -
Relying on provisioners for critical infrastructure setup. If a provisioner fails, the resource is tainted and will be destroyed on the next apply. For critical setup steps (like registering a service with a load balancer), use provider-native resources that Terraform can manage declaratively.
Common Pitfalls
- Provisioners are not shown in
terraform plan. You will not see what a provisioner will do during the plan phase. This makes them harder to review and audit. remote-execrequires network access. If the instance is in a private subnet with no bastion or VPN, the SSH connection will time out. Use a bastion host or switch tolocal-execwith SSM.- Destroy-time provisioners cannot reference other resources. They can only use
selfand literals. Referencing another resource's attributes will cause an error. - Provisioner failure taints the resource. If a provisioner fails during creation, the resource is marked as tainted and will be destroyed and recreated on the next apply, even if the infrastructure itself was created successfully.
- Null resource triggers are string-only. All trigger values are converted to strings. A list or map used as a trigger value will cause an error; use
jsonencode()to serialize complex values. - Provisioners run once. They execute only on creation (or destruction). If you need ongoing configuration management, use a dedicated tool like Ansible, Chef, or Puppet.
- Forgetting
depends_onfor implicit ordering. If a provisioner needs a resource that is not referenced in the provisioner block, Terraform does not know about the dependency. Add an explicitdepends_on.
Install this skill directly: skilldb add terraform-skills
Related Skills
Terraform Basics
Terraform fundamentals including providers, resources, data sources, and core workflow
Terraform CI CD Pipeline
Running Terraform in CI/CD pipelines with automated plan, approval gates, and safe apply workflows
Terraform Modules
Terraform module design, composition, versioning, and reuse patterns
Terraform State Management
Remote state backends, state locking, import, migration, and state surgery techniques
Terraform Testing
Testing Terraform configurations with native tests, Terratest, plan validation, and policy-as-code
Terraform Variables Outputs
Terraform variables, outputs, locals, type constraints, validation, and data flow patterns