Should You Use Terraform Provisioners? Best Practices and Alternatives
Terraform provisioners enable execution of scripts on local or remote machines during resource creation or destruction, bridging the gap between Terraform's declarative model and imperative configuration needs. While HashiCorp explicitly recommends using provisioners only as a last resort, understanding them remains essential for handling edge cases in infrastructure automation. This comprehensive guide covers everything you need to know about provisioners, their limitations, and more optimal alternatives.
Table of Contents
- Introduction to Terraform Provisioners
- How Provisioners Work
- Types of Provisioners
- Provisioner Behavior and Configuration
- Best Practices and HashiCorp Recommendations
- Limitations and Drawbacks
- Practical Examples
- Alternatives to Provisioners
- Decision Framework: When to Use Provisioners vs Alternatives
- Conclusion
Introduction to Terraform Provisioners
Terraform provisioners are components that execute scripts or commands on local or remote machines as part of resource creation or destruction. They represent a pragmatic solution for handling configuration tasks that cannot be directly modeled in Terraform's declarative approach.
HashiCorp defines provisioners as a way to "model specific actions on the local machine or on a remote machine in order to prepare servers or other infrastructure objects for service." However, the company has consistently emphasized that provisioners should be used as a last resort when no other options are available.
Provisioners exist outside Terraform's core planning capabilities because Terraform cannot predict the outcomes of arbitrary commands or scripts. This introduces complexity and potential unpredictability into your infrastructure deployment.
How Provisioners Work
Provisioners operate within the context of resources and execute at specific points in the resource lifecycle:
- Creation-time provisioners (the default) run after a resource is successfully created but before it's marked as complete in the state file. These only execute during the initial creation, not during updates.
- Destroy-time provisioners run before a resource is destroyed, enabling cleanup operations or safe decommissioning.
Each provisioner is defined as a block within a resource definition, inheriting the resource's context and accessing its attributes through the self
object:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
provisioner "local-exec" {
command = "echo The server's IP address is ${self.private_ip}"
}
}
When provisioners fail, their parent resource is typically marked as "tainted," which schedules it for recreation on the next apply (though this behavior can be modified).
Types of Provisioners
Terraform currently supports three main provisioner types, each serving different use cases.
local-exec Provisioner
The local-exec
provisioner executes commands on the machine running Terraform, not on the remote resource.
provisioner "local-exec" {
command = "echo ${self.private_ip} >> private_ips.txt"
working_dir = "/optional/path/to/working/directory"
interpreter = ["/bin/bash", "-c"]
environment = {
FOO = "bar"
BAR = "1"
}
when = "create" # or "destroy"
quiet = false
}
Key arguments include:
command
(Required): The command to executeworking_dir
(Optional): Directory where the command will runinterpreter
(Optional): List of interpreter argumentsenvironment
(Optional): Environment variableswhen
(Optional): Execution timing (create or destroy)quiet
(Optional): Suppresses command output if true
Best used for: Recording information about created resources, triggering local scripts, or generating files based on provisioned infrastructure.
remote-exec Provisioner
The remote-exec
provisioner executes commands or scripts on a remote resource after it's created, requiring a connection block to define how to access the resource.
provisioner "remote-exec" {
inline = [
"sudo apt update",
"sudo apt install -y nginx",
"sudo systemctl start nginx"
]
# OR
script = "local_path_to_script.sh"
# OR
scripts = ["first_script.sh", "second_script.sh"]
connection {
type = "ssh" # or "winrm"
user = "username"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
}
The remote-exec
provisioner offers three mutually exclusive ways to specify commands:
inline
: List of commands to executescript
: Path to a local script to copy and executescripts
: List of local scripts to copy and execute in order
Best used for: Installing software, configuring services, or bootstrapping configuration management tools on newly created instances.
file Provisioner
The file
provisioner copies files or directories from the machine running Terraform to the newly created resource.
provisioner "file" {
source = "local/path/to/file.txt"
destination = "/remote/path/file.txt"
# OR
content = "content to copy"
destination = "/remote/path/file.txt"
connection {
# connection details...
}
}
The file
provisioner requires:
- Either
source
(path to copy) orcontent
(content to write) destination
: The remote path where the file will be written- A connection block for SSH or WinRM access
Best used for: Copying configuration files, uploading scripts for later execution, or transferring certificates to servers.
Removed Provisioner Types
In Terraform 0.15.0, HashiCorp removed several specialized provisioners that were previously available:
- Chef Provisioner
- Habitat Provisioner
- Puppet Provisioner
- Salt Masterless Provisioner
These were removed because they were difficult to maintain and often led to poor user experiences. The recommendation is to use the generic provisioners (file, local-exec, remote-exec) to bootstrap these tools if needed.
Provisioner Behavior and Configuration
Connection Blocks
Remote provisioners (file
and remote-exec
) require a connection block to specify how to connect to the target resource:
connection {
type = "ssh" # or "winrm"
user = "username"
password = "password" # Not recommended for production
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
port = 22 # Default for SSH, 5985 for WinRM
timeout = "5m"
# SSH-specific options
bastion_host = "bastion.example.com" # Jump host if needed
bastion_user = "bastion_user"
bastion_private_key = file("~/.ssh/bastion.pem")
# WinRM-specific options
https = true # Use HTTPS for WinRM
insecure = true # Don't validate HTTPS certificates
}
A connection block can appear:
- At the resource level (applies to all provisioners in that resource)
- Within individual provisioner blocks (overrides resource-level connection)
Execution Timing
By default, provisioners run when their parent resource is created. This can be modified with the when
parameter:
provisioner "local-exec" {
when = "destroy" # Run when resource is destroyed
command = "echo 'Destroying resource' >> destruction.log"
}
Important notes about execution timing:
- Creation-time provisioners only execute during initial creation, not during updates
- Destroy-time provisioners don't run if
create_before_destroy = true
is set on the resource - Destroy provisioners only run if they remain in the configuration when the resource is destroyed
Error Handling
The on_failure
parameter determines what happens if a provisioner fails:
provisioner "remote-exec" {
inline = ["command_that_might_fail"]
on_failure = "continue" # Default is "fail"
}
Options:
fail
(default): Marks the resource as tainted for recreation on next applycontinue
: Ignores the error and continues with creation/destruction
For destroy-time provisioners, failures cause Terraform to error and retry on the next apply.
Best Practices and HashiCorp Recommendations
HashiCorp's official stance on provisioners has evolved over time toward greater caution. The current recommendations include:
- Use provisioners as a last resort. Explore alternatives first, such as cloud-init, custom machine images, or native provider functionality.
- Keep provisioning logic minimal. Provisioners should perform only essential bootstrapping tasks, not complex configuration management.
- Use only the standard provisioners (
file
,local-exec
, andremote-exec
). - Manage credentials securely. Never hard-code credentials in provisioner blocks.
- Consider using the
terraform_data
resource (ornull_resource
in older versions) for provisioners not directly tied to a specific resource. - Design provisioner scripts to be idempotent whenever possible.
- Limit the scope of provisioner actions to essential tasks only.
- Use SSH keys instead of passwords for remote authentication.
Limitations and Drawbacks
Provisioners have several significant limitations that should influence your decision to use them:
- Not modeled in plans: Terraform cannot predict the actions of provisioners during planning, making plans less accurate.
- Complexity: Successful use requires coordinating many details including network access, credentials, and software dependencies.
- Limited state tracking: Terraform cannot track the internal state of systems modified by provisioners.
- Manual recovery needed: Failed provisioners can leave resources in partially configured states.
- Not idempotent by default: Unlike Terraform resources, provisioners don't automatically handle idempotency.
- Network dependency: Remote provisioners require direct network access to the provisioned resources.
- Security implications: Provisioners often require authentication credentials that must be carefully managed.
- No support during updates: Provisioners only run during creation or destruction, not during resource updates.
Practical Examples
Using local-exec for Database Initialization
This example uses local-exec
to initialize a database after creation:
resource "aws_db_instance" "postgres" {
# DB configuration...
}
resource "null_resource" "db_setup" {
depends_on = [aws_db_instance.postgres]
provisioner "local-exec" {
command = "PGPASSWORD=${aws_db_instance.postgres.password} psql -h ${aws_db_instance.postgres.address} -U ${aws_db_instance.postgres.username} -d ${aws_db_instance.postgres.name} -f ./init.sql"
}
}
Using remote-exec for Service Configuration
This example configures a web server using remote-exec
:
resource "aws_instance" "app_server" {
# Instance configuration...
connection {
type = "ssh"
user = "ec2-user"
private_key = file("${path.module}/key.pem")
host = self.public_ip
}
provisioner "remote-exec" {
inline = [
"sudo yum update -y",
"sudo amazon-linux-extras install -y lamp-mariadb10.2-php7.2 php7.2",
"sudo yum install -y httpd",
"sudo systemctl start httpd",
"sudo systemctl enable httpd"
]
}
}
Using null_resource with Provisioners
The null_resource
allows provisioners to be executed without being tied to a specific resource:
resource "aws_instance" "cluster" {
count = 3
# Instance configuration...
}
resource "null_resource" "cluster_setup" {
# This forces re-provisioning when instances change
triggers = {
cluster_instance_ids = join(",", aws_instance.cluster[*].id)
}
connection {
host = aws_instance.cluster[0].public_ip
# Connection details...
}
provisioner "remote-exec" {
inline = [
"bootstrap-cluster.sh ${join(" ", aws_instance.cluster[*].private_ip)}"
]
}
}
Alternatives to Provisioners
Given the limitations of provisioners, HashiCorp recommends several alternatives:
Cloud-Init and User Data
Cloud-init is a widely supported method to bootstrap cloud instances without requiring direct access:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
user_data = <<-EOF
#!/bin/bash
apt-get update
apt-get install -y nginx
systemctl start nginx
systemctl enable nginx
echo "<h1>Deployed via Terraform</h1>" > /var/www/html/index.html
EOF
}
For more complex configurations, the cloudinit_config
data source offers greater flexibility:
data "cloudinit_config" "config" {
gzip = true
base64_encode = true
part {
content_type = "text/cloud-config"
content = <<-EOF
#cloud-config
package_update: true
packages:
- nginx
- python3-pip
EOF
}
part {
content_type = "text/x-shellscript"
content = <<-EOF
#!/bin/bash
systemctl start nginx
systemctl enable nginx
EOF
}
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
user_data = data.cloudinit_config.config.rendered
}
Packer for Image Building
Packer allows you to create pre-configured machine images that can be deployed by Terraform, eliminating the need for post-deployment configuration:
# After building an AMI with Packer
data "aws_ami" "web_server" {
most_recent = true
owners = ["self"]
filter {
name = "name"
values = ["web-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.web_server.id
instance_type = "t2.micro"
# No provisioners needed, everything is pre-configured in the AMI
}
Configuration Management Tools
Configuration management tools like Ansible can be triggered after Terraform creates resources:
resource "aws_instance" "web" {
# Instance configuration...
provisioner "local-exec" {
command = "sleep 30 && ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -i '${self.public_ip},' -u ubuntu --private-key=${path.module}/ansible-key.pem playbook.yml"
}
}
A better approach is to create an Ansible inventory dynamically and run Ansible as a separate step outside of Terraform.
Native Provider Capabilities
Many Terraform providers offer native resources that eliminate the need for provisioners:
# Azure example using Custom Script Extension
resource "azurerm_virtual_machine_extension" "web" {
name = "web-server-setup"
virtual_machine_id = azurerm_linux_virtual_machine.web.id
publisher = "Microsoft.Azure.Extensions"
type = "CustomScript"
type_handler_version = "2.0"
settings = <<SETTINGS
{
"commandToExecute": "apt-get update && apt-get install -y nginx && systemctl enable nginx && systemctl start nginx"
}
SETTINGS
}
terraform_data Resource
In Terraform 1.4+, the terraform_data
resource replaced null_resource
and provides better integration with Terraform's lifecycle:
resource "terraform_data" "cluster_setup" {
# This triggers re-provisioning when instances change
triggers_replace = aws_instance.cluster[*].id
connection {
host = aws_instance.cluster[0].public_ip
# Connection details...
}
provisioner "remote-exec" {
inline = [
"bootstrap-cluster.sh ${join(" ", aws_instance.cluster[*].private_ip)}"
]
}
}
Decision Framework: When to Use Provisioners vs Alternatives
Use Provisioners When:
- Ephemeral configuration is needed - one-time initialization that doesn't need tracking
- Provider limitations prevent using native resources
- Custom workflows require integration with external systems during provisioning
- Cleanup actions are needed during resource destruction
- No alternatives exist for your specific use case
Use Alternatives When:
- Immutable infrastructure is your approach - prefer Packer-built images
- Scaling requirements dictate instances will launch without Terraform (use cloud-init)
- Configuration complexity is high - use dedicated configuration management tools
- Long-term maintenance is important - native provider features are better tracked
- Security concerns exist around network access and credentials
This decision matrix summarizes the tradeoffs:
Approach | Best For | Advantages | Disadvantages |
---|---|---|---|
Provisioners | Quick customization, one-off tasks | Simple setup, direct execution | Not tracked in state, requires network access |
Cloud-init/User Data | Cloud instances, auto-scaling | Works without Terraform present | Limited to boot-time execution |
Packer | Production environments | Pre-configured, consistent | Requires additional build step |
Configuration Management | Complex application stacks | Specialized for configuration | Additional tooling required |
Native Provider Capabilities | Most production workloads | Tracked in state, better error handling | Provider-specific |
Conclusion
Terraform provisioners provide a bridge between Terraform's declarative infrastructure definition and imperative configuration needs. However, their limitations make them suitable only as a last resort when better alternatives aren't available.
The evolution of HashiCorp's stance on provisioners reflects the industry's broader shift toward more declarative, immutable infrastructure approaches. By understanding when provisioners are appropriate and when alternatives are better, you can build more robust, maintainable infrastructure with Terraform.
For most production scenarios, consider alternatives like cloud-init, Packer images, configuration management tools, or native provider capabilities that offer better reliability, maintainability, and security. Reserve provisioners for those edge cases where declarative approaches simply aren't possible, and ensure you understand their limitations and behavior when you do use them.