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

  1. Introduction to Terraform Provisioners
  2. How Provisioners Work
  3. Types of Provisioners
  4. Provisioner Behavior and Configuration
  5. Best Practices and HashiCorp Recommendations
  6. Limitations and Drawbacks
  7. Practical Examples
  8. Alternatives to Provisioners
  9. Decision Framework: When to Use Provisioners vs Alternatives
  10. 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:

  1. 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.
  2. 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 execute
  • working_dir (Optional): Directory where the command will run
  • interpreter (Optional): List of interpreter arguments
  • environment (Optional): Environment variables
  • when (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 execute
  • script: Path to a local script to copy and execute
  • scripts: 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) or content (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 apply
  • continue: 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:

  1. Use provisioners as a last resort. Explore alternatives first, such as cloud-init, custom machine images, or native provider functionality.
  2. Keep provisioning logic minimal. Provisioners should perform only essential bootstrapping tasks, not complex configuration management.
  3. Use only the standard provisioners (file, local-exec, and remote-exec).
  4. Manage credentials securely. Never hard-code credentials in provisioner blocks.
  5. Consider using the terraform_data resource (or null_resource in older versions) for provisioners not directly tied to a specific resource.
  6. Design provisioner scripts to be idempotent whenever possible.
  7. Limit the scope of provisioner actions to essential tasks only.
  8. 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:

  1. Not modeled in plans: Terraform cannot predict the actions of provisioners during planning, making plans less accurate.
  2. Complexity: Successful use requires coordinating many details including network access, credentials, and software dependencies.
  3. Limited state tracking: Terraform cannot track the internal state of systems modified by provisioners.
  4. Manual recovery needed: Failed provisioners can leave resources in partially configured states.
  5. Not idempotent by default: Unlike Terraform resources, provisioners don't automatically handle idempotency.
  6. Network dependency: Remote provisioners require direct network access to the provisioned resources.
  7. Security implications: Provisioners often require authentication credentials that must be carefully managed.
  8. 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:

  1. Ephemeral configuration is needed - one-time initialization that doesn't need tracking
  2. Provider limitations prevent using native resources
  3. Custom workflows require integration with external systems during provisioning
  4. Cleanup actions are needed during resource destruction
  5. No alternatives exist for your specific use case

Use Alternatives When:

  1. Immutable infrastructure is your approach - prefer Packer-built images
  2. Scaling requirements dictate instances will launch without Terraform (use cloud-init)
  3. Configuration complexity is high - use dedicated configuration management tools
  4. Long-term maintenance is important - native provider features are better tracked
  5. 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.